diff --git a/.eslintrc.json b/.eslintrc.json index 1fc849b5c..c6592adcd 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,36 +1,42 @@ { "root": true, "env": { "es6": true }, "extends": [ "eslint:recommended", "plugin:react/recommended", "plugin:flowtype/recommended", "plugin:import/errors", "plugin:import/warnings", "plugin:prettier/recommended", "prettier/react", "prettier/flowtype" ], "parser": "babel-eslint", "plugins": ["react", "react-hooks", "flowtype", "monorepo", "import"], "rules": { "linebreak-style": "error", "semi": "error", "react-hooks/rules-of-hooks": "error", "react-hooks/exhaustive-deps": "warn", "monorepo/no-relative-import": "error", "no-empty": ["error", { "allowEmptyCatch": true }], "import/no-unresolved": 0, "no-unused-vars": ["error", { "ignoreRestSiblings": true }], "react/prop-types": ["error", { "skipUndeclared": true }], - "no-shadow": 1 + "no-shadow": 1, + "import/order": ["warn", { "newlines-between": "always", + "alphabetize": { + "order": "asc", + "caseInsensitive": true + } + }] }, "settings": { "react": { "version": "detect" }, "import/ignore": ["react-native"] } } diff --git a/lib/actions/activity-actions.js b/lib/actions/activity-actions.js index 484969106..c9cbeaf0d 100644 --- a/lib/actions/activity-actions.js +++ b/lib/actions/activity-actions.js @@ -1,56 +1,56 @@ // @flow -import type { FetchJSON } from '../utils/fetch-json'; import type { ActivityUpdate, ActivityUpdateSuccessPayload, SetThreadUnreadStatusPayload, SetThreadUnreadStatusRequest, SetThreadUnreadStatusResult, } from '../types/activity-types'; +import type { FetchJSON } from '../utils/fetch-json'; const updateActivityActionTypes = Object.freeze({ started: 'UPDATE_ACTIVITY_STARTED', success: 'UPDATE_ACTIVITY_SUCCESS', failed: 'UPDATE_ACTIVITY_FAILED', }); async function updateActivity( fetchJSON: FetchJSON, activityUpdates: $ReadOnlyArray, ): Promise { const response = await fetchJSON('update_activity', { updates: activityUpdates, }); return { activityUpdates, result: { unfocusedToUnread: response.unfocusedToUnread, }, }; } const setThreadUnreadStatusActionTypes = Object.freeze({ started: 'SET_THREAD_UNREAD_STATUS_STARTED', success: 'SET_THREAD_UNREAD_STATUS_SUCCESS', failed: 'SET_THREAD_UNREAD_STATUS_FAILED', }); async function setThreadUnreadStatus( fetchJSON: FetchJSON, request: SetThreadUnreadStatusRequest, ): Promise { const response: SetThreadUnreadStatusResult = await fetchJSON( 'set_thread_unread_status', request, ); return { resetToUnread: response.resetToUnread, threadID: request.threadID, }; } export { updateActivityActionTypes, updateActivity, setThreadUnreadStatusActionTypes, setThreadUnreadStatus, }; diff --git a/lib/actions/device-actions.js b/lib/actions/device-actions.js index 4288f72ad..001f5c497 100644 --- a/lib/actions/device-actions.js +++ b/lib/actions/device-actions.js @@ -1,23 +1,22 @@ // @flow -import type { FetchJSON } from '../utils/fetch-json'; - import { getConfig } from '../utils/config'; +import type { FetchJSON } from '../utils/fetch-json'; const setDeviceTokenActionTypes = Object.freeze({ started: 'SET_DEVICE_TOKEN_STARTED', success: 'SET_DEVICE_TOKEN_SUCCESS', failed: 'SET_DEVICE_TOKEN_FAILED', }); async function setDeviceToken( fetchJSON: FetchJSON, deviceToken: string, ): Promise { await fetchJSON('update_device_token', { deviceToken, platformDetails: getConfig().platformDetails, }); return deviceToken; } export { setDeviceTokenActionTypes, setDeviceToken }; diff --git a/lib/actions/entry-actions.js b/lib/actions/entry-actions.js index dfdc6f522..247b858be 100644 --- a/lib/actions/entry-actions.js +++ b/lib/actions/entry-actions.js @@ -1,186 +1,185 @@ // @flow import type { RawEntryInfo, CalendarQuery, SaveEntryInfo, SaveEntryResponse, CreateEntryInfo, CreateEntryPayload, DeleteEntryInfo, DeleteEntryResponse, RestoreEntryInfo, RestoreEntryResponse, FetchEntryInfosResult, CalendarQueryUpdateResult, } from '../types/entry-types'; -import type { FetchJSON } from '../utils/fetch-json'; import type { HistoryRevisionInfo } from '../types/history-types'; - import { dateFromString } from '../utils/date-utils'; +import type { FetchJSON } from '../utils/fetch-json'; const fetchEntriesActionTypes = Object.freeze({ started: 'FETCH_ENTRIES_STARTED', success: 'FETCH_ENTRIES_SUCCESS', failed: 'FETCH_ENTRIES_FAILED', }); async function fetchEntries( fetchJSON: FetchJSON, calendarQuery: CalendarQuery, ): Promise { const response = await fetchJSON('fetch_entries', calendarQuery); return { rawEntryInfos: response.rawEntryInfos, }; } const updateCalendarQueryActionTypes = Object.freeze({ started: 'UPDATE_CALENDAR_QUERY_STARTED', success: 'UPDATE_CALENDAR_QUERY_SUCCESS', failed: 'UPDATE_CALENDAR_QUERY_FAILED', }); async function updateCalendarQuery( fetchJSON: FetchJSON, calendarQuery: CalendarQuery, reduxAlreadyUpdated: boolean = false, ): Promise { const response = await fetchJSON('update_calendar_query', calendarQuery); const { rawEntryInfos, deletedEntryIDs } = response; return { rawEntryInfos, deletedEntryIDs, calendarQuery, calendarQueryAlreadyUpdated: reduxAlreadyUpdated, }; } const createLocalEntryActionType = 'CREATE_LOCAL_ENTRY'; function createLocalEntry( threadID: string, localID: number, dateString: string, creatorID: string, ): RawEntryInfo { const date = dateFromString(dateString); const newEntryInfo: RawEntryInfo = { localID: `local${localID}`, threadID, text: '', year: date.getFullYear(), month: date.getMonth() + 1, day: date.getDate(), creationTime: Date.now(), creatorID, deleted: false, }; return newEntryInfo; } const createEntryActionTypes = Object.freeze({ started: 'CREATE_ENTRY_STARTED', success: 'CREATE_ENTRY_SUCCESS', failed: 'CREATE_ENTRY_FAILED', }); async function createEntry( fetchJSON: FetchJSON, request: CreateEntryInfo, ): Promise { const result = await fetchJSON('create_entry', request); return { entryID: result.entryID, newMessageInfos: result.newMessageInfos, threadID: request.threadID, localID: request.localID, updatesResult: result.updatesResult, }; } const saveEntryActionTypes = Object.freeze({ started: 'SAVE_ENTRY_STARTED', success: 'SAVE_ENTRY_SUCCESS', failed: 'SAVE_ENTRY_FAILED', }); const concurrentModificationResetActionType = 'CONCURRENT_MODIFICATION_RESET'; async function saveEntry( fetchJSON: FetchJSON, request: SaveEntryInfo, ): Promise { const result = await fetchJSON('update_entry', request); return { entryID: result.entryID, newMessageInfos: result.newMessageInfos, updatesResult: result.updatesResult, }; } const deleteEntryActionTypes = Object.freeze({ started: 'DELETE_ENTRY_STARTED', success: 'DELETE_ENTRY_SUCCESS', failed: 'DELETE_ENTRY_FAILED', }); async function deleteEntry( fetchJSON: FetchJSON, info: DeleteEntryInfo, ): Promise { const response = await fetchJSON('delete_entry', { ...info, timestamp: Date.now(), }); return { newMessageInfos: response.newMessageInfos, threadID: response.threadID, updatesResult: response.updatesResult, }; } const fetchRevisionsForEntryActionTypes = Object.freeze({ started: 'FETCH_REVISIONS_FOR_ENTRY_STARTED', success: 'FETCH_REVISIONS_FOR_ENTRY_SUCCESS', failed: 'FETCH_REVISIONS_FOR_ENTRY_FAILED', }); async function fetchRevisionsForEntry( fetchJSON: FetchJSON, entryID: string, ): Promise<$ReadOnlyArray> { const response = await fetchJSON('fetch_entry_revisions', { id: entryID }); return response.result; } const restoreEntryActionTypes = Object.freeze({ started: 'RESTORE_ENTRY_STARTED', success: 'RESTORE_ENTRY_SUCCESS', failed: 'RESTORE_ENTRY_FAILED', }); async function restoreEntry( fetchJSON: FetchJSON, info: RestoreEntryInfo, ): Promise { const response = await fetchJSON('restore_entry', { ...info, timestamp: Date.now(), }); return { newMessageInfos: response.newMessageInfos, updatesResult: response.updatesResult, }; } export { fetchEntriesActionTypes, fetchEntries, updateCalendarQueryActionTypes, updateCalendarQuery, createLocalEntryActionType, createLocalEntry, createEntryActionTypes, createEntry, saveEntryActionTypes, concurrentModificationResetActionType, saveEntry, deleteEntryActionTypes, deleteEntry, fetchRevisionsForEntryActionTypes, fetchRevisionsForEntry, restoreEntryActionTypes, restoreEntry, }; diff --git a/lib/actions/message-actions.js b/lib/actions/message-actions.js index d5f57e78e..b701caba0 100644 --- a/lib/actions/message-actions.js +++ b/lib/actions/message-actions.js @@ -1,115 +1,115 @@ // @flow -import type { FetchJSON } from '../utils/fetch-json'; import type { FetchMessageInfosPayload, SendMessageResult, } from '../types/message-types'; +import type { FetchJSON } from '../utils/fetch-json'; const fetchMessagesBeforeCursorActionTypes = Object.freeze({ started: 'FETCH_MESSAGES_BEFORE_CURSOR_STARTED', success: 'FETCH_MESSAGES_BEFORE_CURSOR_SUCCESS', failed: 'FETCH_MESSAGES_BEFORE_CURSOR_FAILED', }); async function fetchMessagesBeforeCursor( fetchJSON: FetchJSON, threadID: string, beforeMessageID: string, ): Promise { const response = await fetchJSON('fetch_messages', { cursors: { [threadID]: beforeMessageID, }, }); return { threadID, rawMessageInfos: response.rawMessageInfos, truncationStatus: response.truncationStatuses[threadID], }; } const fetchMostRecentMessagesActionTypes = Object.freeze({ started: 'FETCH_MOST_RECENT_MESSAGES_STARTED', success: 'FETCH_MOST_RECENT_MESSAGES_SUCCESS', failed: 'FETCH_MOST_RECENT_MESSAGES_FAILED', }); async function fetchMostRecentMessages( fetchJSON: FetchJSON, threadID: string, ): Promise { const response = await fetchJSON('fetch_messages', { cursors: { [threadID]: null, }, }); return { threadID, rawMessageInfos: response.rawMessageInfos, truncationStatus: response.truncationStatuses[threadID], }; } const sendTextMessageActionTypes = Object.freeze({ started: 'SEND_TEXT_MESSAGE_STARTED', success: 'SEND_TEXT_MESSAGE_SUCCESS', failed: 'SEND_TEXT_MESSAGE_FAILED', }); async function sendTextMessage( fetchJSON: FetchJSON, threadID: string, localID: string, text: string, ): Promise { const response = await fetchJSON('create_text_message', { threadID, localID, text, }); return { id: response.newMessageInfo.id, time: response.newMessageInfo.time, }; } const createLocalMessageActionType = 'CREATE_LOCAL_MESSAGE'; const sendMultimediaMessageActionTypes = Object.freeze({ started: 'SEND_MULTIMEDIA_MESSAGE_STARTED', success: 'SEND_MULTIMEDIA_MESSAGE_SUCCESS', failed: 'SEND_MULTIMEDIA_MESSAGE_FAILED', }); async function sendMultimediaMessage( fetchJSON: FetchJSON, threadID: string, localID: string, mediaIDs: $ReadOnlyArray, ): Promise { const response = await fetchJSON('create_multimedia_message', { threadID, localID, mediaIDs, }); return { id: response.newMessageInfo.id, time: response.newMessageInfo.time, }; } const saveMessagesActionType = 'SAVE_MESSAGES'; const processMessagesActionType = 'PROCESS_MESSAGES'; const messageStorePruneActionType = 'MESSAGE_STORE_PRUNE'; export { fetchMessagesBeforeCursorActionTypes, fetchMessagesBeforeCursor, fetchMostRecentMessagesActionTypes, fetchMostRecentMessages, sendTextMessageActionTypes, sendTextMessage, createLocalMessageActionType, sendMultimediaMessageActionTypes, sendMultimediaMessage, saveMessagesActionType, processMessagesActionType, messageStorePruneActionType, }; diff --git a/lib/actions/relationship-actions.js b/lib/actions/relationship-actions.js index bc7849df6..1c74e3567 100644 --- a/lib/actions/relationship-actions.js +++ b/lib/actions/relationship-actions.js @@ -1,34 +1,33 @@ // @flow import type { RelationshipRequest, RelationshipErrors, } from '../types/relationship-types'; -import type { FetchJSON } from '../utils/fetch-json'; - import { ServerError } from '../utils/errors'; +import type { FetchJSON } from '../utils/fetch-json'; const updateRelationshipsActionTypes = Object.freeze({ started: 'UPDATE_RELATIONSHIPS_STARTED', success: 'UPDATE_RELATIONSHIPS_SUCCESS', failed: 'UPDATE_RELATIONSHIPS_FAILED', }); async function updateRelationships( fetchJSON: FetchJSON, request: RelationshipRequest, ): Promise { const errors = await fetchJSON('update_relationships', request); const { invalid_user, already_friends, user_blocked } = errors; if (invalid_user) { throw new ServerError('invalid_user', errors); } else if (already_friends) { throw new ServerError('already_friends', errors); } else if (user_blocked) { throw new ServerError('user_blocked', errors); } return errors; } export { updateRelationshipsActionTypes, updateRelationships }; diff --git a/lib/actions/report-actions.js b/lib/actions/report-actions.js index 7663296e3..4c1729a55 100644 --- a/lib/actions/report-actions.js +++ b/lib/actions/report-actions.js @@ -1,43 +1,43 @@ // @flow -import type { FetchJSON } from '../utils/fetch-json'; import type { ClientReportCreationRequest, ReportCreationResponse, } from '../types/report-types'; +import type { FetchJSON } from '../utils/fetch-json'; const sendReportActionTypes = Object.freeze({ started: 'SEND_REPORT_STARTED', success: 'SEND_REPORT_SUCCESS', failed: 'SEND_REPORT_FAILED', }); const fetchJSONOptions = { timeout: 60000 }; async function sendReport( fetchJSON: FetchJSON, request: ClientReportCreationRequest, ): Promise { const response = await fetchJSON('create_report', request, fetchJSONOptions); return { id: response.id }; } const sendReportsActionTypes = Object.freeze({ started: 'SEND_REPORTS_STARTED', success: 'SEND_REPORTS_SUCCESS', failed: 'SEND_REPORTS_FAILED', }); async function sendReports( fetchJSON: FetchJSON, reports: $ReadOnlyArray, ): Promise { await fetchJSON('create_reports', { reports }, fetchJSONOptions); } const queueReportsActionType = 'QUEUE_REPORTS'; export { sendReportActionTypes, sendReport, sendReportsActionTypes, sendReports, queueReportsActionType, }; diff --git a/lib/actions/thread-actions.js b/lib/actions/thread-actions.js index 50c068281..91812d452 100644 --- a/lib/actions/thread-actions.js +++ b/lib/actions/thread-actions.js @@ -1,172 +1,171 @@ // @flow +import invariant from 'invariant'; + import type { ChangeThreadSettingsPayload, LeaveThreadPayload, UpdateThreadRequest, NewThreadRequest, NewThreadResult, ClientThreadJoinRequest, ThreadJoinPayload, } from '../types/thread-types'; import type { FetchJSON } from '../utils/fetch-json'; - -import invariant from 'invariant'; - import { values } from '../utils/objects'; const deleteThreadActionTypes = Object.freeze({ started: 'DELETE_THREAD_STARTED', success: 'DELETE_THREAD_SUCCESS', failed: 'DELETE_THREAD_FAILED', }); async function deleteThread( fetchJSON: FetchJSON, threadID: string, currentAccountPassword: string, ): Promise { const response = await fetchJSON('delete_thread', { threadID, accountPassword: currentAccountPassword, }); return { updatesResult: response.updatesResult, }; } const changeThreadSettingsActionTypes = Object.freeze({ started: 'CHANGE_THREAD_SETTINGS_STARTED', success: 'CHANGE_THREAD_SETTINGS_SUCCESS', failed: 'CHANGE_THREAD_SETTINGS_FAILED', }); async function changeThreadSettings( fetchJSON: FetchJSON, request: UpdateThreadRequest, ): Promise { const response = await fetchJSON('update_thread', request); invariant( Object.keys(request.changes).length > 0, 'No changes provided to changeThreadSettings!', ); return { threadID: request.threadID, updatesResult: response.updatesResult, newMessageInfos: response.newMessageInfos, }; } const removeUsersFromThreadActionTypes = Object.freeze({ started: 'REMOVE_USERS_FROM_THREAD_STARTED', success: 'REMOVE_USERS_FROM_THREAD_SUCCESS', failed: 'REMOVE_USERS_FROM_THREAD_FAILED', }); async function removeUsersFromThread( fetchJSON: FetchJSON, threadID: string, memberIDs: string[], ): Promise { const response = await fetchJSON('remove_members', { threadID, memberIDs, }); return { threadID, updatesResult: response.updatesResult, newMessageInfos: response.newMessageInfos, }; } const changeThreadMemberRolesActionTypes = Object.freeze({ started: 'CHANGE_THREAD_MEMBER_ROLES_STARTED', success: 'CHANGE_THREAD_MEMBER_ROLES_SUCCESS', failed: 'CHANGE_THREAD_MEMBER_ROLES_FAILED', }); async function changeThreadMemberRoles( fetchJSON: FetchJSON, threadID: string, memberIDs: string[], newRole: string, ): Promise { const response = await fetchJSON('update_role', { threadID, memberIDs, role: newRole, }); return { threadID, updatesResult: response.updatesResult, newMessageInfos: response.newMessageInfos, }; } const newThreadActionTypes = Object.freeze({ started: 'NEW_THREAD_STARTED', success: 'NEW_THREAD_SUCCESS', failed: 'NEW_THREAD_FAILED', }); async function newThread( fetchJSON: FetchJSON, request: NewThreadRequest, ): Promise { const response = await fetchJSON('create_thread', request); return { newThreadID: response.newThreadID, updatesResult: response.updatesResult, newMessageInfos: response.newMessageInfos, }; } const joinThreadActionTypes = Object.freeze({ started: 'JOIN_THREAD_STARTED', success: 'JOIN_THREAD_SUCCESS', failed: 'JOIN_THREAD_FAILED', }); async function joinThread( fetchJSON: FetchJSON, request: ClientThreadJoinRequest, ): Promise { const response = await fetchJSON('join_thread', request); const userInfos = values(response.userInfos); return { updatesResult: response.updatesResult, rawMessageInfos: response.rawMessageInfos, truncationStatuses: response.truncationStatuses, userInfos, calendarResult: { calendarQuery: request.calendarQuery, rawEntryInfos: response.rawEntryInfos, }, }; } const leaveThreadActionTypes = Object.freeze({ started: 'LEAVE_THREAD_STARTED', success: 'LEAVE_THREAD_SUCCESS', failed: 'LEAVE_THREAD_FAILED', }); async function leaveThread( fetchJSON: FetchJSON, threadID: string, ): Promise { const response = await fetchJSON('leave_thread', { threadID }); return { updatesResult: response.updatesResult, }; } export { deleteThreadActionTypes, deleteThread, changeThreadSettingsActionTypes, changeThreadSettings, removeUsersFromThreadActionTypes, removeUsersFromThread, changeThreadMemberRolesActionTypes, changeThreadMemberRoles, newThreadActionTypes, newThread, joinThreadActionTypes, joinThread, leaveThreadActionTypes, leaveThread, }; diff --git a/lib/actions/upload-actions.js b/lib/actions/upload-actions.js index 63d096b7a..5e146be8c 100644 --- a/lib/actions/upload-actions.js +++ b/lib/actions/upload-actions.js @@ -1,68 +1,68 @@ // @flow -import type { FetchJSON } from '../utils/fetch-json'; import type { UploadMultimediaResult, Dimensions } from '../types/media-types'; +import type { FetchJSON } from '../utils/fetch-json'; import type { UploadBlob } from '../utils/upload-blob'; export type MultimediaUploadCallbacks = $Shape<{| onProgress: (percent: number) => void, abortHandler: (abort: () => void) => void, uploadBlob: UploadBlob, |}>; export type MultimediaUploadExtras = $Shape<{| ...Dimensions, loop: boolean |}>; async function uploadMultimedia( fetchJSON: FetchJSON, multimedia: Object, extras: MultimediaUploadExtras, callbacks?: MultimediaUploadCallbacks, ): Promise { const onProgress = callbacks && callbacks.onProgress; const abortHandler = callbacks && callbacks.abortHandler; const uploadBlob = callbacks && callbacks.uploadBlob; const stringExtras = {}; if (extras.height !== null && extras.height !== undefined) { stringExtras.height = extras.height.toString(); } if (extras.width !== null && extras.width !== undefined) { stringExtras.width = extras.width.toString(); } if (extras.loop) { stringExtras.loop = '1'; } const response = await fetchJSON( 'upload_multimedia', { multimedia: [multimedia], ...stringExtras, }, { onProgress, abortHandler, blobUpload: uploadBlob ? uploadBlob : true, }, ); const [uploadResult] = response.results; return { id: uploadResult.id, uri: uploadResult.uri, dimensions: uploadResult.dimensions, mediaType: uploadResult.mediaType, loop: uploadResult.loop, }; } const updateMultimediaMessageMediaActionType = 'UPDATE_MULTIMEDIA_MESSAGE_MEDIA'; async function deleteUpload(fetchJSON: FetchJSON, id: string): Promise { await fetchJSON('delete_upload', { id }); } export { uploadMultimedia, updateMultimediaMessageMediaActionType, deleteUpload, }; diff --git a/lib/actions/user-actions.js b/lib/actions/user-actions.js index dde0b5a4b..f32dfc925 100644 --- a/lib/actions/user-actions.js +++ b/lib/actions/user-actions.js @@ -1,312 +1,311 @@ // @flow -import type { FetchJSON } from '../utils/fetch-json'; -import type { HandleVerificationCodeResult } from '../types/verify-types'; -import type { UserInfo, AccountUpdate } from '../types/user-types'; +import threadWatcher from '../shared/thread-watcher'; import type { ChangeUserSettingsResult, LogOutResult, LogInInfo, LogInResult, RegisterResult, UpdatePasswordInfo, RegisterInfo, AccessRequest, } from '../types/account-types'; +import type { UserSearchResult } from '../types/search-types'; +import type { PreRequestUserState } from '../types/session-types'; import type { SubscriptionUpdateRequest, SubscriptionUpdateResult, } from '../types/subscription-types'; -import type { UserSearchResult } from '../types/search-types'; -import type { PreRequestUserState } from '../types/session-types'; - -import threadWatcher from '../shared/thread-watcher'; +import type { UserInfo, AccountUpdate } from '../types/user-types'; +import type { HandleVerificationCodeResult } from '../types/verify-types'; import { getConfig } from '../utils/config'; +import type { FetchJSON } from '../utils/fetch-json'; import sleep from '../utils/sleep'; const logOutActionTypes = Object.freeze({ started: 'LOG_OUT_STARTED', success: 'LOG_OUT_SUCCESS', failed: 'LOG_OUT_FAILED', }); async function logOut( fetchJSON: FetchJSON, preRequestUserState: PreRequestUserState, ): Promise { let response = null; try { response = await Promise.race([ fetchJSON('log_out', {}), (async () => { await sleep(500); throw new Error('log_out took more than 500ms'); })(), ]); } catch {} const currentUserInfo = response ? response.currentUserInfo : null; return { currentUserInfo, preRequestUserState }; } const deleteAccountActionTypes = Object.freeze({ started: 'DELETE_ACCOUNT_STARTED', success: 'DELETE_ACCOUNT_SUCCESS', failed: 'DELETE_ACCOUNT_FAILED', }); async function deleteAccount( fetchJSON: FetchJSON, password: string, preRequestUserState: PreRequestUserState, ): Promise { const response = await fetchJSON('delete_account', { password }); return { currentUserInfo: response.currentUserInfo, preRequestUserState }; } const registerActionTypes = Object.freeze({ started: 'REGISTER_STARTED', success: 'REGISTER_SUCCESS', failed: 'REGISTER_FAILED', }); async function register( fetchJSON: FetchJSON, registerInfo: RegisterInfo, ): Promise { const response = await fetchJSON('create_account', { ...registerInfo, platformDetails: getConfig().platformDetails, }); return { currentUserInfo: { id: response.id, username: registerInfo.username, email: registerInfo.email, emailVerified: false, }, rawMessageInfos: response.rawMessageInfos, threadInfos: response.cookieChange.threadInfos, userInfos: response.cookieChange.userInfos, calendarQuery: registerInfo.calendarQuery, }; } function mergeUserInfos(...userInfoArrays: UserInfo[][]): UserInfo[] { const merged = {}; for (let userInfoArray of userInfoArrays) { for (let userInfo of userInfoArray) { merged[userInfo.id] = userInfo; } } const flattened = []; for (let id in merged) { flattened.push(merged[id]); } return flattened; } const cookieInvalidationResolutionAttempt = 'COOKIE_INVALIDATION_RESOLUTION_ATTEMPT'; const appStartNativeCredentialsAutoLogIn = 'APP_START_NATIVE_CREDENTIALS_AUTO_LOG_IN'; const appStartReduxLoggedInButInvalidCookie = 'APP_START_REDUX_LOGGED_IN_BUT_INVALID_COOKIE'; const socketAuthErrorResolutionAttempt = 'SOCKET_AUTH_ERROR_RESOLUTION_ATTEMPT'; const logInActionTypes = Object.freeze({ started: 'LOG_IN_STARTED', success: 'LOG_IN_SUCCESS', failed: 'LOG_IN_FAILED', }); async function logIn( fetchJSON: FetchJSON, logInInfo: LogInInfo, ): Promise { const watchedIDs = threadWatcher.getWatchedIDs(); const { source, ...restLogInInfo } = logInInfo; const response = await fetchJSON('log_in', { ...restLogInInfo, watchedIDs, platformDetails: getConfig().platformDetails, }); const userInfos = mergeUserInfos( response.userInfos, response.cookieChange.userInfos, ); return { threadInfos: response.cookieChange.threadInfos, currentUserInfo: response.currentUserInfo, calendarResult: { calendarQuery: logInInfo.calendarQuery, rawEntryInfos: response.rawEntryInfos, }, messagesResult: { messageInfos: response.rawMessageInfos, truncationStatus: response.truncationStatuses, watchedIDsAtRequestTime: watchedIDs, currentAsOf: response.serverTime, }, userInfos, updatesCurrentAsOf: response.serverTime, source, }; } const resetPasswordActionTypes = Object.freeze({ started: 'RESET_PASSWORD_STARTED', success: 'RESET_PASSWORD_SUCCESS', failed: 'RESET_PASSWORD_FAILED', }); async function resetPassword( fetchJSON: FetchJSON, updatePasswordInfo: UpdatePasswordInfo, ): Promise { const watchedIDs = threadWatcher.getWatchedIDs(); const response = await fetchJSON('update_password', { ...updatePasswordInfo, watchedIDs, platformDetails: getConfig().platformDetails, }); const userInfos = mergeUserInfos( response.userInfos, response.cookieChange.userInfos, ); return { threadInfos: response.cookieChange.threadInfos, currentUserInfo: response.currentUserInfo, calendarResult: { calendarQuery: updatePasswordInfo.calendarQuery, rawEntryInfos: response.rawEntryInfos, }, messagesResult: { messageInfos: response.rawMessageInfos, truncationStatus: response.truncationStatuses, watchedIDsAtRequestTime: watchedIDs, currentAsOf: response.serverTime, }, userInfos, updatesCurrentAsOf: response.serverTime, }; } const forgotPasswordActionTypes = Object.freeze({ started: 'FORGOT_PASSWORD_STARTED', success: 'FORGOT_PASSWORD_SUCCESS', failed: 'FORGOT_PASSWORD_FAILED', }); async function forgotPassword( fetchJSON: FetchJSON, usernameOrEmail: string, ): Promise { await fetchJSON('send_password_reset_email', { usernameOrEmail }); } const changeUserSettingsActionTypes = Object.freeze({ started: 'CHANGE_USER_SETTINGS_STARTED', success: 'CHANGE_USER_SETTINGS_SUCCESS', failed: 'CHANGE_USER_SETTINGS_FAILED', }); async function changeUserSettings( fetchJSON: FetchJSON, accountUpdate: AccountUpdate, ): Promise { await fetchJSON('update_account', accountUpdate); return { email: accountUpdate.updatedFields.email }; } const resendVerificationEmailActionTypes = Object.freeze({ started: 'RESEND_VERIFICATION_EMAIL_STARTED', success: 'RESEND_VERIFICATION_EMAIL_SUCCESS', failed: 'RESEND_VERIFICATION_EMAIL_FAILED', }); async function resendVerificationEmail(fetchJSON: FetchJSON): Promise { await fetchJSON('send_verification_email', {}); } const handleVerificationCodeActionTypes = Object.freeze({ started: 'HANDLE_VERIFICATION_CODE_STARTED', success: 'HANDLE_VERIFICATION_CODE_SUCCESS', failed: 'HANDLE_VERIFICATION_CODE_FAILED', }); async function handleVerificationCode( fetchJSON: FetchJSON, code: string, ): Promise { const result = await fetchJSON('verify_code', { code }); const { verifyField, resetPasswordUsername } = result; return { verifyField, resetPasswordUsername }; } const searchUsersActionTypes = Object.freeze({ started: 'SEARCH_USERS_STARTED', success: 'SEARCH_USERS_SUCCESS', failed: 'SEARCH_USERS_FAILED', }); async function searchUsers( fetchJSON: FetchJSON, usernamePrefix: string, ): Promise { const response = await fetchJSON('search_users', { prefix: usernamePrefix }); return { userInfos: response.userInfos, }; } const updateSubscriptionActionTypes = Object.freeze({ started: 'UPDATE_SUBSCRIPTION_STARTED', success: 'UPDATE_SUBSCRIPTION_SUCCESS', failed: 'UPDATE_SUBSCRIPTION_FAILED', }); async function updateSubscription( fetchJSON: FetchJSON, subscriptionUpdate: SubscriptionUpdateRequest, ): Promise { const response = await fetchJSON( 'update_user_subscription', subscriptionUpdate, ); return { threadID: subscriptionUpdate.threadID, subscription: response.threadSubscription, }; } const requestAccessActionTypes = Object.freeze({ started: 'REQUEST_ACCESS_STARTED', success: 'REQUEST_ACCESS_SUCCESS', failed: 'REQUEST_ACCESS_FAILED', }); async function requestAccess( fetchJSON: FetchJSON, accessRequest: AccessRequest, ): Promise { await fetchJSON('request_access', accessRequest); } export { logOutActionTypes, logOut, deleteAccountActionTypes, deleteAccount, registerActionTypes, register, cookieInvalidationResolutionAttempt, appStartNativeCredentialsAutoLogIn, appStartReduxLoggedInButInvalidCookie, socketAuthErrorResolutionAttempt, logInActionTypes, logIn, resetPasswordActionTypes, resetPassword, forgotPasswordActionTypes, forgotPassword, changeUserSettingsActionTypes, changeUserSettings, resendVerificationEmailActionTypes, resendVerificationEmail, handleVerificationCodeActionTypes, handleVerificationCode, searchUsersActionTypes, searchUsers, updateSubscriptionActionTypes, updateSubscription, requestAccessActionTypes, requestAccess, }; diff --git a/lib/media/file-utils.js b/lib/media/file-utils.js index e42e1ba08..cd5d9146f 100644 --- a/lib/media/file-utils.js +++ b/lib/media/file-utils.js @@ -1,198 +1,198 @@ // @flow -import type { MediaType } from '../types/media-types'; - import fileType from 'file-type'; import invariant from 'invariant'; +import type { MediaType } from '../types/media-types'; + type ResultMIME = 'image/png' | 'image/jpeg'; type MediaConfig = {| mediaType: 'photo' | 'video' | 'photo_or_video', extension: string, serverCanTranscode: boolean, imageConfig?: $Shape<{| convertTo: ResultMIME, |}>, videoConfig?: $Shape<{| loop: boolean, |}>, |}; const mediaConfig: { [mime: string]: MediaConfig } = Object.freeze({ 'image/png': { mediaType: 'photo', extension: 'png', serverCanTranscode: true, }, 'image/jpeg': { mediaType: 'photo', extension: 'jpg', serverCanTranscode: true, }, 'image/gif': { mediaType: 'photo_or_video', extension: 'gif', serverCanTranscode: true, imageConfig: { convertTo: 'image/png', }, videoConfig: { loop: true, }, }, 'image/heic': { mediaType: 'photo', extension: 'heic', serverCanTranscode: false, imageConfig: { convertTo: 'image/jpeg', }, }, 'image/webp': { mediaType: 'photo', extension: 'webp', serverCanTranscode: true, imageConfig: { convertTo: 'image/jpeg', }, }, 'image/tiff': { mediaType: 'photo', extension: 'tiff', serverCanTranscode: true, imageConfig: { convertTo: 'image/jpeg', }, }, 'image/svg+xml': { mediaType: 'photo', extension: 'svg', serverCanTranscode: true, imageConfig: { convertTo: 'image/png', }, }, 'image/bmp': { mediaType: 'photo', extension: 'bmp', serverCanTranscode: true, imageConfig: { convertTo: 'image/png', }, }, 'video/mp4': { mediaType: 'video', extension: 'mp4', serverCanTranscode: false, }, 'video/quicktime': { mediaType: 'video', extension: 'mp4', serverCanTranscode: false, }, }); const serverTranscodableTypes: Set<$Keys> = new Set(); for (let mime in mediaConfig) { if (mediaConfig[mime].serverCanTranscode) { serverTranscodableTypes.add(mime); } } function getTargetMIME(inputMIME: string): ResultMIME { const config = mediaConfig[inputMIME]; if (!config) { return 'image/jpeg'; } const targetMIME = config.imageConfig && config.imageConfig.convertTo; if (targetMIME) { return targetMIME; } invariant( inputMIME === 'image/png' || inputMIME === 'image/jpeg', 'all images must be converted to jpeg or png', ); return inputMIME; } const bytesNeededForFileTypeCheck = 64; export type FileDataInfo = {| mime: ?string, mediaType: ?MediaType, |}; function fileInfoFromData( data: Uint8Array | Buffer | ArrayBuffer, ): FileDataInfo { const fileTypeResult = fileType(data); if (!fileTypeResult) { return { mime: null, mediaType: null }; } const { mime } = fileTypeResult; const rawMediaType = mediaConfig[mime] && mediaConfig[mime].mediaType; const mediaType = rawMediaType === 'photo_or_video' ? 'photo' : rawMediaType; return { mime, mediaType }; } function replaceExtension(filename: string, ext: string): string { const lastIndex = filename.lastIndexOf('.'); let name = lastIndex >= 0 ? filename.substring(0, lastIndex) : filename; if (!name) { name = Math.random().toString(36).slice(-5); } const maxReadableLength = 255 - ext.length - 1; return `${name.substring(0, maxReadableLength)}.${ext}`; } function readableFilename(filename: string, mime: string): ?string { const ext = mediaConfig[mime] && mediaConfig[mime].extension; if (!ext) { return null; } return replaceExtension(filename, ext); } const extRegex = /\.([0-9a-z]+)$/i; function extensionFromFilename(filename: string): ?string { const matches = filename.match(extRegex); if (!matches) { return null; } const match = matches[1]; if (!match) { return null; } return match.toLowerCase(); } const pathRegex = /^file:\/\/(.*)$/; function pathFromURI(uri: string): ?string { const matches = uri.match(pathRegex); if (!matches) { return null; } return matches[1] ? matches[1] : null; } const filenameRegex = /[^/]+$/; function filenameFromPathOrURI(pathOrURI: string): ?string { const matches = pathOrURI.match(filenameRegex); if (!matches) { return null; } return matches[0] ? matches[0] : null; } export { mediaConfig, serverTranscodableTypes, getTargetMIME, bytesNeededForFileTypeCheck, fileInfoFromData, replaceExtension, readableFilename, extensionFromFilename, pathFromURI, filenameFromPathOrURI, }; diff --git a/lib/media/media-utils.js b/lib/media/media-utils.js index 0f4e87e55..e7c91fe5a 100644 --- a/lib/media/media-utils.js +++ b/lib/media/media-utils.js @@ -1,58 +1,58 @@ // @flow +import invariant from 'invariant'; + +import type { PlatformDetails } from '../types/device-types'; import type { Media } from '../types/media-types'; import type { MultimediaMessageInfo, RawMultimediaMessageInfo, } from '../types/message-types'; -import type { PlatformDetails } from '../types/device-types'; - -import invariant from 'invariant'; const maxDimensions = Object.freeze({ width: 1920, height: 1920 }); const localhostRegex = /^http:\/\/localhost/; function shimUploadURI(uri: string, platformDetails: ?PlatformDetails) { if (!platformDetails || platformDetails.platform !== 'android') { return uri; } // We do this for testing in the Android emulator return uri.replace(localhostRegex, 'http://10.0.2.2'); } function contentStringForMediaArray(media: $ReadOnlyArray): string { invariant(media.length > 0, 'there should be some media'); if (media.length === 1) { return `a ${media[0].type}`; } let firstType; for (let single of media) { if (!firstType) { firstType = single.type; } if (firstType === single.type) { continue; } else { return 'some media'; } } invariant(firstType, 'there should be some media'); if (firstType === 'photo') { firstType = 'image'; } return `some ${firstType}s`; } function multimediaMessagePreview( messageInfo: MultimediaMessageInfo | RawMultimediaMessageInfo, ): string { const mediaContentString = contentStringForMediaArray(messageInfo.media); return `sent ${mediaContentString}`; } export { maxDimensions, shimUploadURI, contentStringForMediaArray, multimediaMessagePreview, }; diff --git a/lib/media/video-utils.js b/lib/media/video-utils.js index bc5f0ff1b..58c59054b 100644 --- a/lib/media/video-utils.js +++ b/lib/media/video-utils.js @@ -1,146 +1,146 @@ // @flow -import type { Dimensions, MediaMissionFailure } from '../types/media-types'; - import invariant from 'invariant'; +import type { Dimensions, MediaMissionFailure } from '../types/media-types'; +import { getUUID } from '../utils/uuid'; + import { replaceExtension } from './file-utils'; import { maxDimensions } from './media-utils'; -import { getUUID } from '../utils/uuid'; const { height: maxHeight, width: maxWidth } = maxDimensions; const estimatedResultBitrate = 0.35; // in MiB/s type Input = {| inputPath: string, inputHasCorrectContainerAndCodec: boolean, inputFileSize: number, // in bytes inputFilename: string, inputDuration: number, inputDimensions: Dimensions, outputDirectory: string, outputCodec: string, clientConnectionInfo?: {| hasWiFi: boolean, speed: number, // in kilobytes per second |}, clientTranscodeSpeed?: number, // in input video seconds per second |}; type ProcessPlan = {| action: 'process', outputPath: string, ffmpegCommand: string, |}; type Plan = | {| action: 'none' |} | {| action: 'reject', failure: MediaMissionFailure |} | ProcessPlan; function getVideoProcessingPlan(input: Input): Plan { const { inputPath, inputHasCorrectContainerAndCodec, inputFileSize, inputFilename, inputDuration, inputDimensions, outputDirectory, outputCodec, clientConnectionInfo, clientTranscodeSpeed, } = input; if (inputDuration > videoDurationLimit * 60) { return { action: 'reject', failure: { success: false, reason: 'video_too_long', duration: inputDuration, }, }; } if (inputHasCorrectContainerAndCodec) { if (inputFileSize < 1e7) { return { action: 'none' }; } if (clientConnectionInfo && clientTranscodeSpeed) { const rawUploadTime = inputFileSize / 1024 / clientConnectionInfo.speed; // in seconds const transcodeTime = inputDuration / clientTranscodeSpeed; // in seconds const estimatedResultFileSize = inputDuration * estimatedResultBitrate * 1024; // in KiB const transcodedUploadTime = estimatedResultFileSize / clientConnectionInfo.speed; // in seconds const fullProcessTime = transcodeTime + transcodedUploadTime; if ( (clientConnectionInfo.hasWiFi && rawUploadTime < fullProcessTime) || (inputFileSize < 1e8 && rawUploadTime * 2 < fullProcessTime) ) { return { action: 'none' }; } } } const outputFilename = replaceExtension( `transcode.${getUUID()}.${inputFilename}`, 'mp4', ); const outputPath = `${outputDirectory}${outputFilename}`; let quality, speed, scale; if (outputCodec === 'h264') { const { floor, min, max, log2 } = Math; const crf = floor(min(5, max(0, log2(inputDuration / 5)))) + 23; quality = `-crf ${crf}`; speed = '-preset ultrafast'; scale = `-vf scale=${maxWidth}:${maxHeight}:force_original_aspect_ratio=decrease`; } else if (outputCodec === 'h264_videotoolbox') { quality = '-profile:v baseline'; speed = '-realtime 1'; const { width, height } = inputDimensions; scale = ''; const exceedsDimensions = width > maxWidth || height > maxHeight; if (exceedsDimensions && width / height > maxWidth / maxHeight) { scale = `-vf scale=${maxWidth}:-1`; } else if (exceedsDimensions) { scale = `-vf scale=-1:${maxHeight}`; } } else { invariant(false, `unrecognized outputCodec ${outputCodec}`); } const ffmpegCommand = `-i ${inputPath} ` + `-c:a copy -c:v ${outputCodec} ` + `${quality} ` + '-vsync 2 -r 30 ' + `${scale} ` + `${speed} ` + '-movflags +faststart ' + '-pix_fmt yuv420p ' + '-v quiet ' + outputPath; return { action: 'process', outputPath, ffmpegCommand }; } function getHasMultipleFramesProbeCommand(path: string) { const ffprobeCommand = '-v error ' + '-count_frames ' + '-select_streams v:0 ' + '-show_entries stream=nb_read_frames ' + '-of default=nokey=1:noprint_wrappers=1 ' + '-read_intervals "%+#2" ' + path; return ffprobeCommand; } const videoDurationLimit = 3; // in minutes export { getVideoProcessingPlan, getHasMultipleFramesProbeCommand, videoDurationLimit, }; diff --git a/lib/reducers/calendar-filters-reducer.js b/lib/reducers/calendar-filters-reducer.js index 4ae046430..232baf1ee 100644 --- a/lib/reducers/calendar-filters-reducer.js +++ b/lib/reducers/calendar-filters-reducer.js @@ -1,170 +1,169 @@ // @flow +import { + newThreadActionTypes, + joinThreadActionTypes, + leaveThreadActionTypes, + deleteThreadActionTypes, +} from '../actions/thread-actions'; +import { + logOutActionTypes, + deleteAccountActionTypes, + logInActionTypes, + resetPasswordActionTypes, + registerActionTypes, +} from '../actions/user-actions'; +import { + filteredThreadIDs, + nonThreadCalendarFilters, + nonExcludeDeletedCalendarFilters, +} from '../selectors/calendar-filter-selectors'; +import { threadInFilterList } from '../shared/thread-utils'; import { type CalendarFilter, defaultCalendarFilters, updateCalendarThreadFilter, clearCalendarThreadFilter, setCalendarDeletedFilter, calendarThreadFilterTypes, } from '../types/filter-types'; import type { BaseAction } from '../types/redux-types'; +import { + fullStateSyncActionType, + incrementalStateSyncActionType, +} from '../types/socket-types'; import type { RawThreadInfo } from '../types/thread-types'; import { updateTypes, type UpdateInfo, processUpdatesActionType, } from '../types/update-types'; -import { - fullStateSyncActionType, - incrementalStateSyncActionType, -} from '../types/socket-types'; - -import { - logOutActionTypes, - deleteAccountActionTypes, - logInActionTypes, - resetPasswordActionTypes, - registerActionTypes, -} from '../actions/user-actions'; import { setNewSessionActionType } from '../utils/action-utils'; -import { - filteredThreadIDs, - nonThreadCalendarFilters, - nonExcludeDeletedCalendarFilters, -} from '../selectors/calendar-filter-selectors'; -import { threadInFilterList } from '../shared/thread-utils'; -import { - newThreadActionTypes, - joinThreadActionTypes, - leaveThreadActionTypes, - deleteThreadActionTypes, -} from '../actions/thread-actions'; export default function reduceCalendarFilters( state: $ReadOnlyArray, action: BaseAction, ): $ReadOnlyArray { if ( action.type === logOutActionTypes.success || action.type === deleteAccountActionTypes.success || action.type === logInActionTypes.success || action.type === registerActionTypes.success || action.type === resetPasswordActionTypes.success || (action.type === setNewSessionActionType && action.payload.sessionChange.cookieInvalidated) ) { return defaultCalendarFilters; } else if (action.type === updateCalendarThreadFilter) { const nonThreadFilters = nonThreadCalendarFilters(state); return [ ...nonThreadFilters, { type: calendarThreadFilterTypes.THREAD_LIST, threadIDs: action.payload.threadIDs, }, ]; } else if (action.type === clearCalendarThreadFilter) { return nonThreadCalendarFilters(state); } else if (action.type === setCalendarDeletedFilter) { const otherFilters = nonExcludeDeletedCalendarFilters(state); if (action.payload.includeDeleted && otherFilters.length === state.length) { // Attempting to remove NOT_DELETED filter, but it doesn't exist return state; } else if (action.payload.includeDeleted) { // Removing NOT_DELETED filter return otherFilters; } else if (otherFilters.length < state.length) { // Attempting to add NOT_DELETED filter, but it already exists return state; } else { // Adding NOT_DELETED filter return [...state, { type: calendarThreadFilterTypes.NOT_DELETED }]; } } else if ( action.type === newThreadActionTypes.success || action.type === joinThreadActionTypes.success || action.type === leaveThreadActionTypes.success || action.type === deleteThreadActionTypes.success || action.type === processUpdatesActionType ) { return updateFilterListFromUpdateInfos( state, action.payload.updatesResult.newUpdates, ); } else if (action.type === incrementalStateSyncActionType) { return updateFilterListFromUpdateInfos( state, action.payload.updatesResult.newUpdates, ); } else if (action.type === fullStateSyncActionType) { return removeDeletedThreadIDsFromFilterList( state, action.payload.threadInfos, ); } return state; } function updateFilterListFromUpdateInfos( state: $ReadOnlyArray, updateInfos: $ReadOnlyArray, ): $ReadOnlyArray { const currentlyFilteredIDs = filteredThreadIDs(state); if (!currentlyFilteredIDs) { return state; } let changeOccurred = false; for (let update of updateInfos) { if (update.type === updateTypes.DELETE_THREAD) { const result = currentlyFilteredIDs.delete(update.threadID); if (result) { changeOccurred = true; } } else if (update.type === updateTypes.JOIN_THREAD) { if ( !threadInFilterList(update.threadInfo) || currentlyFilteredIDs.has(update.threadInfo.id) ) { continue; } currentlyFilteredIDs.add(update.threadInfo.id); changeOccurred = true; } else if (update.type === updateTypes.UPDATE_THREAD) { if (threadInFilterList(update.threadInfo)) { continue; } const result = currentlyFilteredIDs.delete(update.threadInfo.id); if (result) { changeOccurred = true; } } } if (changeOccurred) { return [ ...nonThreadCalendarFilters(state), { type: 'threads', threadIDs: [...currentlyFilteredIDs] }, ]; } return state; } function removeDeletedThreadIDsFromFilterList( state: $ReadOnlyArray, threadInfos: { [id: string]: RawThreadInfo }, ): $ReadOnlyArray { const currentlyFilteredIDs = filteredThreadIDs(state); if (!currentlyFilteredIDs) { return state; } const filtered = [...currentlyFilteredIDs].filter((threadID) => threadInFilterList(threadInfos[threadID]), ); if (filtered.length < currentlyFilteredIDs.size) { return [ ...nonThreadCalendarFilters(state), { type: 'threads', threadIDs: filtered }, ]; } return state; } diff --git a/lib/reducers/connection-reducer.js b/lib/reducers/connection-reducer.js index 4bacb0a04..4552e16de 100644 --- a/lib/reducers/connection-reducer.js +++ b/lib/reducers/connection-reducer.js @@ -1,120 +1,120 @@ // @flow +import { updateActivityActionTypes } from '../actions/activity-actions'; +import { updateCalendarQueryActionTypes } from '../actions/entry-actions'; +import { + logOutActionTypes, + deleteAccountActionTypes, + logInActionTypes, + resetPasswordActionTypes, + registerActionTypes, +} from '../actions/user-actions'; +import { queueActivityUpdatesActionType } from '../types/activity-types'; +import { defaultCalendarQuery } from '../types/entry-types'; import { type BaseAction, rehydrateActionType } from '../types/redux-types'; import { type ConnectionInfo, updateConnectionStatusActionType, fullStateSyncActionType, incrementalStateSyncActionType, setLateResponseActionType, updateDisconnectedBarActionType, } from '../types/socket-types'; -import { defaultCalendarQuery } from '../types/entry-types'; -import { queueActivityUpdatesActionType } from '../types/activity-types'; -import { unsupervisedBackgroundActionType } from './foreground-reducer'; - import { setNewSessionActionType } from '../utils/action-utils'; -import { - logOutActionTypes, - deleteAccountActionTypes, - logInActionTypes, - resetPasswordActionTypes, - registerActionTypes, -} from '../actions/user-actions'; -import { updateCalendarQueryActionTypes } from '../actions/entry-actions'; -import { updateActivityActionTypes } from '../actions/activity-actions'; import { getConfig } from '../utils/config'; +import { unsupervisedBackgroundActionType } from './foreground-reducer'; + export default function reduceConnectionInfo( state: ConnectionInfo, action: BaseAction, ): ConnectionInfo { if (action.type === updateConnectionStatusActionType) { return { ...state, status: action.payload.status, lateResponses: [] }; } else if (action.type === unsupervisedBackgroundActionType) { return { ...state, status: 'disconnected', lateResponses: [] }; } else if (action.type === queueActivityUpdatesActionType) { const { activityUpdates } = action.payload; return { ...state, queuedActivityUpdates: [ ...state.queuedActivityUpdates.filter((existingUpdate) => { for (let activityUpdate of activityUpdates) { if ( ((existingUpdate.focus && activityUpdate.focus) || (existingUpdate.focus === false && activityUpdate.focus !== undefined)) && existingUpdate.threadID === activityUpdate.threadID ) { return false; } } return true; }), ...activityUpdates, ], }; } else if (action.type === updateActivityActionTypes.success) { const { payload } = action; return { ...state, queuedActivityUpdates: state.queuedActivityUpdates.filter( (activityUpdate) => !payload.activityUpdates.includes(activityUpdate), ), }; } else if ( action.type === logOutActionTypes.success || action.type === deleteAccountActionTypes.success || (action.type === setNewSessionActionType && action.payload.sessionChange.cookieInvalidated) ) { return { ...state, queuedActivityUpdates: [], actualizedCalendarQuery: defaultCalendarQuery( getConfig().platformDetails.platform, ), }; } else if ( action.type === logInActionTypes.success || action.type === resetPasswordActionTypes.success ) { return { ...state, actualizedCalendarQuery: action.payload.calendarResult.calendarQuery, }; } else if ( action.type === registerActionTypes.success || action.type === updateCalendarQueryActionTypes.success || action.type === fullStateSyncActionType || action.type === incrementalStateSyncActionType ) { return { ...state, actualizedCalendarQuery: action.payload.calendarQuery, }; } else if (action.type === rehydrateActionType) { if (!action.payload || !action.payload.connection) { return state; } return { ...action.payload.connection, status: 'connecting', queuedActivityUpdates: [], lateResponses: [], showDisconnectedBar: false, }; } else if (action.type === setLateResponseActionType) { const { messageID, isLate } = action.payload; const lateResponsesSet = new Set(state.lateResponses); if (isLate) { lateResponsesSet.add(messageID); } else { lateResponsesSet.delete(messageID); } return { ...state, lateResponses: [...lateResponsesSet] }; } else if (action.type === updateDisconnectedBarActionType) { return { ...state, showDisconnectedBar: action.payload.visible }; } return state; } diff --git a/lib/reducers/data-loaded-reducer.js b/lib/reducers/data-loaded-reducer.js index 80dbc7515..a94fad3fd 100644 --- a/lib/reducers/data-loaded-reducer.js +++ b/lib/reducers/data-loaded-reducer.js @@ -1,34 +1,34 @@ // @flow -import type { BaseAction } from '../types/redux-types'; import { logOutActionTypes, deleteAccountActionTypes, logInActionTypes, registerActionTypes, resetPasswordActionTypes, } from '../actions/user-actions'; +import type { BaseAction } from '../types/redux-types'; import { setNewSessionActionType } from '../utils/action-utils'; export default function reduceDataLoaded(state: boolean, action: BaseAction) { if ( action.type === logInActionTypes.success || action.type === resetPasswordActionTypes.success || action.type === registerActionTypes.success ) { return true; } else if ( action.type === setNewSessionActionType && action.payload.sessionChange.currentUserInfo && action.payload.sessionChange.currentUserInfo.anonymous ) { return false; } else if ( action.type === logOutActionTypes.started || action.type === logOutActionTypes.success || action.type === deleteAccountActionTypes.success ) { return false; } return state; } diff --git a/lib/reducers/entry-reducer.js b/lib/reducers/entry-reducer.js index 3899e8efa..dd5c10bca 100644 --- a/lib/reducers/entry-reducer.js +++ b/lib/reducers/entry-reducer.js @@ -1,705 +1,704 @@ // @flow -import type { BaseAction } from '../types/redux-types'; -import type { - RawEntryInfo, - EntryStore, - CalendarQuery, -} from '../types/entry-types'; -import { type RawThreadInfo } from '../types/thread-types'; -import { - updateTypes, - type UpdateInfo, - processUpdatesActionType, -} from '../types/update-types'; -import { - serverRequestTypes, - processServerRequestsActionType, -} from '../types/request-types'; -import { - fullStateSyncActionType, - incrementalStateSyncActionType, -} from '../types/socket-types'; -import { - sendReportActionTypes, - sendReportsActionTypes, -} from '../actions/report-actions'; -import { - type ClientEntryInconsistencyReportCreationRequest, - reportTypes, -} from '../types/report-types'; - -import _flow from 'lodash/fp/flow'; -import _map from 'lodash/fp/map'; -import _pickBy from 'lodash/fp/pickBy'; -import _omitBy from 'lodash/fp/omitBy'; -import _mapValues from 'lodash/fp/mapValues'; +import invariant from 'invariant'; import _filter from 'lodash/fp/filter'; -import _union from 'lodash/fp/union'; -import _mapKeys from 'lodash/fp/mapKeys'; +import _flow from 'lodash/fp/flow'; import _groupBy from 'lodash/fp/groupBy'; import _isEqual from 'lodash/fp/isEqual'; +import _map from 'lodash/fp/map'; +import _mapKeys from 'lodash/fp/mapKeys'; +import _mapValues from 'lodash/fp/mapValues'; +import _omitBy from 'lodash/fp/omitBy'; +import _pickBy from 'lodash/fp/pickBy'; import _sortBy from 'lodash/fp/sortBy'; -import invariant from 'invariant'; +import _union from 'lodash/fp/union'; -import { dateString } from '../utils/date-utils'; -import { setNewSessionActionType } from '../utils/action-utils'; import { fetchEntriesActionTypes, updateCalendarQueryActionTypes, createLocalEntryActionType, createEntryActionTypes, saveEntryActionTypes, concurrentModificationResetActionType, deleteEntryActionTypes, fetchRevisionsForEntryActionTypes, restoreEntryActionTypes, } from '../actions/entry-actions'; import { - logOutActionTypes, - deleteAccountActionTypes, - logInActionTypes, - resetPasswordActionTypes, -} from '../actions/user-actions'; + sendReportActionTypes, + sendReportsActionTypes, +} from '../actions/report-actions'; import { deleteThreadActionTypes, leaveThreadActionTypes, joinThreadActionTypes, changeThreadSettingsActionTypes, removeUsersFromThreadActionTypes, changeThreadMemberRolesActionTypes, } from '../actions/thread-actions'; +import { + logOutActionTypes, + deleteAccountActionTypes, + logInActionTypes, + resetPasswordActionTypes, +} from '../actions/user-actions'; import { entryID, filterRawEntryInfosByCalendarQuery, serverEntryInfosObject, } from '../shared/entry-utils'; import { threadInFilterList } from '../shared/thread-utils'; -import { getConfig } from '../utils/config'; +import type { + RawEntryInfo, + EntryStore, + CalendarQuery, +} from '../types/entry-types'; +import type { BaseAction } from '../types/redux-types'; +import { + type ClientEntryInconsistencyReportCreationRequest, + reportTypes, +} from '../types/report-types'; +import { + serverRequestTypes, + processServerRequestsActionType, +} from '../types/request-types'; +import { + fullStateSyncActionType, + incrementalStateSyncActionType, +} from '../types/socket-types'; +import { type RawThreadInfo } from '../types/thread-types'; +import { + updateTypes, + type UpdateInfo, + processUpdatesActionType, +} from '../types/update-types'; import { actionLogger } from '../utils/action-logger'; +import { setNewSessionActionType } from '../utils/action-utils'; +import { getConfig } from '../utils/config'; +import { dateString } from '../utils/date-utils'; import { values } from '../utils/objects'; import { sanitizeAction } from '../utils/sanitization'; function daysToEntriesFromEntryInfos(entryInfos: $ReadOnlyArray) { return _flow( _sortBy((['id', 'localID']: $ReadOnlyArray)), _groupBy((entryInfo: RawEntryInfo) => dateString(entryInfo.year, entryInfo.month, entryInfo.day), ), _mapValues((entryInfoGroup: RawEntryInfo[]) => _map(entryID)(entryInfoGroup), ), )([...entryInfos]); } function filterExistingDaysToEntriesWithNewEntryInfos( oldDaysToEntries: { [id: string]: string[] }, newEntryInfos: { [id: string]: RawEntryInfo }, ) { return _mapValues((entryIDs: string[]) => _filter((id: string) => newEntryInfos[id])(entryIDs), )(oldDaysToEntries); } function mergeNewEntryInfos( currentEntryInfos: { [id: string]: RawEntryInfo }, currentDaysToEntries: ?{ [day: string]: string[] }, newEntryInfos: $ReadOnlyArray, threadInfos: { [id: string]: RawThreadInfo }, ) { const mergedEntryInfos = {}; let someEntryUpdated = false; for (let rawEntryInfo of newEntryInfos) { const serverID = rawEntryInfo.id; invariant(serverID, 'new entryInfos should have serverID'); const currentEntryInfo = currentEntryInfos[serverID]; let newEntryInfo; if (currentEntryInfo && currentEntryInfo.localID) { newEntryInfo = { id: serverID, // Try to preserve localIDs. This is because we use them as React // keys and changing React keys leads to loss of component state. localID: currentEntryInfo.localID, threadID: rawEntryInfo.threadID, text: rawEntryInfo.text, year: rawEntryInfo.year, month: rawEntryInfo.month, day: rawEntryInfo.day, creationTime: rawEntryInfo.creationTime, creatorID: rawEntryInfo.creatorID, deleted: rawEntryInfo.deleted, }; } else { newEntryInfo = { id: serverID, threadID: rawEntryInfo.threadID, text: rawEntryInfo.text, year: rawEntryInfo.year, month: rawEntryInfo.month, day: rawEntryInfo.day, creationTime: rawEntryInfo.creationTime, creatorID: rawEntryInfo.creatorID, deleted: rawEntryInfo.deleted, }; } if (_isEqual(currentEntryInfo)(newEntryInfo)) { mergedEntryInfos[serverID] = currentEntryInfo; } else { mergedEntryInfos[serverID] = newEntryInfo; someEntryUpdated = true; } } for (let id in currentEntryInfos) { const newEntryInfo = mergedEntryInfos[id]; if (!newEntryInfo) { mergedEntryInfos[id] = currentEntryInfos[id]; } } for (let id in mergedEntryInfos) { const entryInfo = mergedEntryInfos[id]; if (!threadInFilterList(threadInfos[entryInfo.threadID])) { someEntryUpdated = true; delete mergedEntryInfos[id]; } } const daysToEntries = !currentDaysToEntries || someEntryUpdated ? daysToEntriesFromEntryInfos(values(mergedEntryInfos)) : currentDaysToEntries; const entryInfos = someEntryUpdated ? mergedEntryInfos : currentEntryInfos; return [entryInfos, daysToEntries]; } function reduceEntryInfos( entryStore: EntryStore, action: BaseAction, newThreadInfos: { [id: string]: RawThreadInfo }, ): EntryStore { const { entryInfos, daysToEntries, lastUserInteractionCalendar, inconsistencyReports, } = entryStore; if ( action.type === logOutActionTypes.success || action.type === deleteAccountActionTypes.success || action.type === deleteThreadActionTypes.success || action.type === leaveThreadActionTypes.success ) { const authorizedThreadInfos = _pickBy(threadInFilterList)(newThreadInfos); const newEntryInfos = _pickBy( (entry: RawEntryInfo) => authorizedThreadInfos[entry.threadID], )(entryInfos); const newLastUserInteractionCalendar = action.type === logOutActionTypes.success || action.type === deleteAccountActionTypes.success ? 0 : lastUserInteractionCalendar; if (Object.keys(newEntryInfos).length === Object.keys(entryInfos).length) { return { entryInfos, daysToEntries, lastUserInteractionCalendar: newLastUserInteractionCalendar, inconsistencyReports, }; } const newDaysToEntries = filterExistingDaysToEntriesWithNewEntryInfos( daysToEntries, newEntryInfos, ); return { entryInfos: newEntryInfos, daysToEntries: newDaysToEntries, lastUserInteractionCalendar: newLastUserInteractionCalendar, inconsistencyReports, }; } else if (action.type === setNewSessionActionType) { const authorizedThreadInfos = _pickBy(threadInFilterList)(newThreadInfos); const newEntryInfos = _pickBy( (entry: RawEntryInfo) => authorizedThreadInfos[entry.threadID], )(entryInfos); const newDaysToEntries = filterExistingDaysToEntriesWithNewEntryInfos( daysToEntries, newEntryInfos, ); const newLastUserInteractionCalendar = action.payload.sessionChange .cookieInvalidated ? 0 : lastUserInteractionCalendar; if (Object.keys(newEntryInfos).length === Object.keys(entryInfos).length) { return { entryInfos, daysToEntries, lastUserInteractionCalendar: newLastUserInteractionCalendar, inconsistencyReports, }; } return { entryInfos: newEntryInfos, daysToEntries: newDaysToEntries, lastUserInteractionCalendar: newLastUserInteractionCalendar, inconsistencyReports, }; } else if (action.type === fetchEntriesActionTypes.success) { const [updatedEntryInfos, updatedDaysToEntries] = mergeNewEntryInfos( entryInfos, daysToEntries, action.payload.rawEntryInfos, newThreadInfos, ); return { entryInfos: updatedEntryInfos, daysToEntries: updatedDaysToEntries, lastUserInteractionCalendar, inconsistencyReports, }; } else if ( action.type === updateCalendarQueryActionTypes.started && action.payload && action.payload.calendarQuery ) { return { entryInfos, daysToEntries, lastUserInteractionCalendar: Date.now(), inconsistencyReports, }; } else if (action.type === updateCalendarQueryActionTypes.success) { const newLastUserInteractionCalendar = action.payload.calendarQuery ? Date.now() : lastUserInteractionCalendar; const [updatedEntryInfos, updatedDaysToEntries] = mergeNewEntryInfos( entryInfos, daysToEntries, action.payload.rawEntryInfos, newThreadInfos, ); const deletionMarkedEntryInfos = markDeletedEntries( updatedEntryInfos, action.payload.deletedEntryIDs, ); return { entryInfos: deletionMarkedEntryInfos, daysToEntries: updatedDaysToEntries, lastUserInteractionCalendar: newLastUserInteractionCalendar, inconsistencyReports, }; } else if (action.type === createLocalEntryActionType) { const entryInfo = action.payload; const localID = entryInfo.localID; invariant(localID, 'localID should be set in CREATE_LOCAL_ENTRY'); const newEntryInfos = { ...entryInfos, [localID]: entryInfo, }; const dayString = dateString( entryInfo.year, entryInfo.month, entryInfo.day, ); const newDaysToEntries = { ...daysToEntries, [dayString]: _union([localID])(daysToEntries[dayString]), }; return { entryInfos: newEntryInfos, daysToEntries: newDaysToEntries, lastUserInteractionCalendar: Date.now(), inconsistencyReports, }; } else if (action.type === createEntryActionTypes.success) { const localID = action.payload.localID; const serverID = action.payload.entryID; // If an entry with this serverID already got into the store somehow // (likely through an unrelated request), we need to dedup them. let rekeyedEntryInfos; if (entryInfos[serverID]) { // It's fair to assume the serverID entry is newer than the localID // entry, and this probably won't happen often, so for now we can just // keep the serverID entry. rekeyedEntryInfos = _omitBy( (candidate: RawEntryInfo) => !candidate.id && candidate.localID === localID, )(entryInfos); } else if (entryInfos[localID]) { rekeyedEntryInfos = _mapKeys((oldKey: string) => entryInfos[oldKey].localID === localID ? serverID : oldKey, )(entryInfos); } else { // This happens if the entry is deauthorized before it's saved return entryStore; } const [updatedEntryInfos, updatedDaysToEntries] = mergeNewEntryInfos( rekeyedEntryInfos, null, mergeUpdateEntryInfos([], action.payload.updatesResult.viewerUpdates), newThreadInfos, ); return { entryInfos: updatedEntryInfos, daysToEntries: updatedDaysToEntries, lastUserInteractionCalendar: Date.now(), inconsistencyReports, }; } else if (action.type === saveEntryActionTypes.success) { const serverID = action.payload.entryID; if ( !entryInfos[serverID] || !threadInFilterList(newThreadInfos[entryInfos[serverID].threadID]) ) { // This happens if the entry is deauthorized before it's saved return entryStore; } const [updatedEntryInfos, updatedDaysToEntries] = mergeNewEntryInfos( entryInfos, daysToEntries, mergeUpdateEntryInfos([], action.payload.updatesResult.viewerUpdates), newThreadInfos, ); return { entryInfos: updatedEntryInfos, daysToEntries: updatedDaysToEntries, lastUserInteractionCalendar: Date.now(), inconsistencyReports, }; } else if (action.type === concurrentModificationResetActionType) { const { payload } = action; if ( !entryInfos[payload.id] || !threadInFilterList(newThreadInfos[entryInfos[payload.id].threadID]) ) { // This happens if the entry is deauthorized before it's restored return entryStore; } const newEntryInfos = { ...entryInfos, [payload.id]: { ...entryInfos[payload.id], text: payload.dbText, }, }; return { entryInfos: newEntryInfos, daysToEntries, lastUserInteractionCalendar, inconsistencyReports, }; } else if (action.type === deleteEntryActionTypes.started) { const payload = action.payload; const id = payload.serverID && entryInfos[payload.serverID] ? payload.serverID : payload.localID; invariant(id, 'either serverID or localID should be set'); const newEntryInfos = { ...entryInfos, [id]: { ...entryInfos[id], deleted: true, }, }; return { entryInfos: newEntryInfos, daysToEntries, lastUserInteractionCalendar: Date.now(), inconsistencyReports, }; } else if (action.type === deleteEntryActionTypes.success && action.payload) { const { payload } = action; const [updatedEntryInfos, updatedDaysToEntries] = mergeNewEntryInfos( entryInfos, daysToEntries, mergeUpdateEntryInfos([], payload.updatesResult.viewerUpdates), newThreadInfos, ); return { entryInfos: updatedEntryInfos, daysToEntries: updatedDaysToEntries, lastUserInteractionCalendar, inconsistencyReports, }; } else if (action.type === fetchRevisionsForEntryActionTypes.success) { const id = action.payload.entryID; if ( !entryInfos[id] || !threadInFilterList(newThreadInfos[entryInfos[id].threadID]) ) { // This happens if the entry is deauthorized before it's restored return entryStore; } // Make sure the entry is in sync with its latest revision const newEntryInfos = { ...entryInfos, [id]: { ...entryInfos[id], text: action.payload.text, deleted: action.payload.deleted, }, }; return { entryInfos: newEntryInfos, daysToEntries, lastUserInteractionCalendar, inconsistencyReports, }; } else if (action.type === restoreEntryActionTypes.success) { const [updatedEntryInfos, updatedDaysToEntries] = mergeNewEntryInfos( entryInfos, daysToEntries, mergeUpdateEntryInfos([], action.payload.updatesResult.viewerUpdates), newThreadInfos, ); return { entryInfos: updatedEntryInfos, daysToEntries: updatedDaysToEntries, lastUserInteractionCalendar: Date.now(), inconsistencyReports, }; } else if ( action.type === logInActionTypes.success || action.type === resetPasswordActionTypes.success || action.type === joinThreadActionTypes.success ) { const { calendarResult } = action.payload; if (calendarResult) { const [updatedEntryInfos, updatedDaysToEntries] = mergeNewEntryInfos( entryInfos, daysToEntries, calendarResult.rawEntryInfos, newThreadInfos, ); return { entryInfos: updatedEntryInfos, daysToEntries: updatedDaysToEntries, lastUserInteractionCalendar, inconsistencyReports, }; } } else if (action.type === incrementalStateSyncActionType) { const [updatedEntryInfos, updatedDaysToEntries] = mergeNewEntryInfos( entryInfos, daysToEntries, mergeUpdateEntryInfos( action.payload.deltaEntryInfos, action.payload.updatesResult.newUpdates, ), newThreadInfos, ); const deletionMarkedEntryInfos = markDeletedEntries( updatedEntryInfos, action.payload.deletedEntryIDs, ); return { entryInfos: deletionMarkedEntryInfos, daysToEntries: updatedDaysToEntries, lastUserInteractionCalendar, inconsistencyReports, }; } else if (action.type === processUpdatesActionType) { const [updatedEntryInfos, updatedDaysToEntries] = mergeNewEntryInfos( entryInfos, daysToEntries, mergeUpdateEntryInfos([], action.payload.updatesResult.newUpdates), newThreadInfos, ); return { entryInfos: updatedEntryInfos, daysToEntries: updatedDaysToEntries, lastUserInteractionCalendar, inconsistencyReports, }; } else if (action.type === fullStateSyncActionType) { const [updatedEntryInfos, updatedDaysToEntries] = mergeNewEntryInfos( entryInfos, daysToEntries, action.payload.rawEntryInfos, newThreadInfos, ); return { entryInfos: updatedEntryInfos, daysToEntries: updatedDaysToEntries, lastUserInteractionCalendar, inconsistencyReports, }; } else if ( action.type === changeThreadSettingsActionTypes.success || action.type === removeUsersFromThreadActionTypes.success || action.type === changeThreadMemberRolesActionTypes.success ) { const authorizedThreadInfos = _pickBy(threadInFilterList)(newThreadInfos); const newEntryInfos = _pickBy( (entry: RawEntryInfo) => authorizedThreadInfos[entry.threadID], )(entryInfos); if (Object.keys(newEntryInfos).length === Object.keys(entryInfos).length) { return entryStore; } const newDaysToEntries = filterExistingDaysToEntriesWithNewEntryInfos( daysToEntries, newEntryInfos, ); return { entryInfos: newEntryInfos, daysToEntries: newDaysToEntries, lastUserInteractionCalendar, inconsistencyReports, }; } else if ( (action.type === sendReportActionTypes.success || action.type === sendReportsActionTypes.success) && action.payload ) { const { payload } = action; const updatedReports = inconsistencyReports.filter( (response) => !payload.reports.includes(response), ); if (updatedReports.length === inconsistencyReports.length) { return entryStore; } return { entryInfos, daysToEntries, lastUserInteractionCalendar, inconsistencyReports: updatedReports, }; } else if (action.type === processServerRequestsActionType) { const checkStateRequest = action.payload.serverRequests.find( (candidate) => candidate.type === serverRequestTypes.CHECK_STATE, ); if (!checkStateRequest || !checkStateRequest.stateChanges) { return entryStore; } const { rawEntryInfos, deleteEntryIDs } = checkStateRequest.stateChanges; if (!rawEntryInfos && !deleteEntryIDs) { return entryStore; } let updatedEntryInfos = { ...entryInfos }; if (deleteEntryIDs) { for (let deleteEntryID of deleteEntryIDs) { delete updatedEntryInfos[deleteEntryID]; } } let updatedDaysToEntries; if (rawEntryInfos) { [updatedEntryInfos, updatedDaysToEntries] = mergeNewEntryInfos( updatedEntryInfos, null, rawEntryInfos, newThreadInfos, ); } else { updatedDaysToEntries = daysToEntriesFromEntryInfos( values(updatedEntryInfos), ); } const newInconsistencies = findInconsistencies( action, entryInfos, updatedEntryInfos, action.payload.calendarQuery, ); return { entryInfos: updatedEntryInfos, daysToEntries: updatedDaysToEntries, lastUserInteractionCalendar, inconsistencyReports: [...inconsistencyReports, ...newInconsistencies], }; } return entryStore; } function mergeUpdateEntryInfos( entryInfos: $ReadOnlyArray, newUpdates: $ReadOnlyArray, ): RawEntryInfo[] { const entryIDs = new Set(entryInfos.map((entryInfo) => entryInfo.id)); const mergedEntryInfos = [...entryInfos]; for (let updateInfo of newUpdates) { if (updateInfo.type === updateTypes.JOIN_THREAD) { for (let entryInfo of updateInfo.rawEntryInfos) { if (entryIDs.has(entryInfo.id)) { continue; } mergedEntryInfos.push(entryInfo); entryIDs.add(entryInfo.id); } } else if (updateInfo.type === updateTypes.UPDATE_ENTRY) { const { entryInfo } = updateInfo; if (entryIDs.has(entryInfo.id)) { continue; } mergedEntryInfos.push(entryInfo); entryIDs.add(entryInfo.id); } } return mergedEntryInfos; } const emptyArray = []; function findInconsistencies( action: BaseAction, beforeStateCheck: { [id: string]: RawEntryInfo }, afterStateCheck: { [id: string]: RawEntryInfo }, calendarQuery: CalendarQuery, ): ClientEntryInconsistencyReportCreationRequest[] { // We don't want to bother reporting an inconsistency if it's just because of // extraneous EntryInfos (not within the current calendarQuery) on either side const filteredBeforeResult = filterRawEntryInfosByCalendarQuery( serverEntryInfosObject(values(beforeStateCheck)), calendarQuery, ); const filteredAfterResult = filterRawEntryInfosByCalendarQuery( serverEntryInfosObject(values(afterStateCheck)), calendarQuery, ); if (_isEqual(filteredBeforeResult)(filteredAfterResult)) { return emptyArray; } return [ { type: reportTypes.ENTRY_INCONSISTENCY, platformDetails: getConfig().platformDetails, beforeAction: beforeStateCheck, action: sanitizeAction(action), calendarQuery, pushResult: afterStateCheck, lastActions: actionLogger.interestingActionSummaries, time: Date.now(), }, ]; } function markDeletedEntries( entryInfos: { [id: string]: RawEntryInfo }, deletedEntryIDs: $ReadOnlyArray, ): { [id: string]: RawEntryInfo } { let result = entryInfos; for (let deletedEntryID of deletedEntryIDs) { const entryInfo = entryInfos[deletedEntryID]; if (!entryInfo || entryInfo.deleted) { continue; } result = { ...result, [deletedEntryID]: { ...entryInfo, deleted: true, }, }; } return result; } export { daysToEntriesFromEntryInfos, reduceEntryInfos }; diff --git a/lib/reducers/loading-reducer.js b/lib/reducers/loading-reducer.js index 5e59dd0f7..66307365e 100644 --- a/lib/reducers/loading-reducer.js +++ b/lib/reducers/loading-reducer.js @@ -1,97 +1,97 @@ // @flow -import type { BaseAction } from '../types/redux-types'; +import _omit from 'lodash/fp/omit'; + import type { LoadingStatus } from '../types/loading-types'; +import type { BaseAction } from '../types/redux-types'; import type { ActionTypes } from '../utils/action-utils'; -import _omit from 'lodash/fp/omit'; - const fetchKeyRegistry: Set = new Set(); const registerFetchKey = (actionTypes: ActionTypes<*, *, *>) => { fetchKeyRegistry.add(actionTypes.started); fetchKeyRegistry.add(actionTypes.success); fetchKeyRegistry.add(actionTypes.failed); }; function reduceLoadingStatuses( state: { [key: string]: { [idx: number]: LoadingStatus } }, action: BaseAction, ): { [key: string]: { [idx: number]: LoadingStatus } } { const startMatch = action.type.match(/(.*)_STARTED/); if (startMatch && fetchKeyRegistry.has(action.type)) { if (!action.loadingInfo || typeof action.loadingInfo !== 'object') { return state; } const { loadingInfo } = action; if (typeof loadingInfo.fetchIndex !== 'number') { return state; } const keyName: string = loadingInfo.customKeyName && typeof loadingInfo.customKeyName === 'string' ? loadingInfo.customKeyName : startMatch[1]; if (loadingInfo.trackMultipleRequests) { return { ...state, [keyName]: { ...state[keyName], [loadingInfo.fetchIndex]: 'loading', }, }; } else { return { ...state, [keyName]: { [loadingInfo.fetchIndex]: 'loading', }, }; } } const failMatch = action.type.match(/(.*)_FAILED/); if (failMatch && fetchKeyRegistry.has(action.type)) { if (!action.loadingInfo || typeof action.loadingInfo !== 'object') { return state; } const { loadingInfo } = action; if (typeof loadingInfo.fetchIndex !== 'number') { return state; } const keyName: string = loadingInfo.customKeyName && typeof loadingInfo.customKeyName === 'string' ? loadingInfo.customKeyName : failMatch[1]; if (state[keyName] && state[keyName][loadingInfo.fetchIndex]) { return { ...state, [keyName]: { ...state[keyName], [loadingInfo.fetchIndex]: 'error', }, }; } return state; } const successMatch = action.type.match(/(.*)_SUCCESS/); if (successMatch && fetchKeyRegistry.has(action.type)) { if (!action.loadingInfo || typeof action.loadingInfo !== 'object') { return state; } const { loadingInfo } = action; if (typeof loadingInfo.fetchIndex !== 'number') { return state; } const keyName: string = loadingInfo.customKeyName && typeof loadingInfo.customKeyName === 'string' ? loadingInfo.customKeyName : successMatch[1]; if (state[keyName] && state[keyName][loadingInfo.fetchIndex]) { const newKeyState = _omit([loadingInfo.fetchIndex.toString()])( state[keyName], ); return { ...state, [keyName]: newKeyState }; } } return state; } export { reduceLoadingStatuses, registerFetchKey }; diff --git a/lib/reducers/local-id-reducer.js b/lib/reducers/local-id-reducer.js index 8097e8937..483315a78 100644 --- a/lib/reducers/local-id-reducer.js +++ b/lib/reducers/local-id-reducer.js @@ -1,41 +1,40 @@ // @flow -import type { BaseAction } from '../types/redux-types'; - import invariant from 'invariant'; -import { - numberFromLocalID, - highestLocalIDSelector, -} from '../selectors/local-id-selectors'; -import { rehydrateActionType } from '../types/redux-types'; +import { createLocalEntryActionType } from '../actions/entry-actions'; import { sendTextMessageActionTypes, sendMultimediaMessageActionTypes, createLocalMessageActionType, } from '../actions/message-actions'; -import { createLocalEntryActionType } from '../actions/entry-actions'; +import { + numberFromLocalID, + highestLocalIDSelector, +} from '../selectors/local-id-selectors'; +import type { BaseAction } from '../types/redux-types'; +import { rehydrateActionType } from '../types/redux-types'; export default function reduceNextLocalID(state: number, action: BaseAction) { let newCandidate = null; if (action.type === rehydrateActionType) { newCandidate = highestLocalIDSelector(action.payload) + 1; if ( action.payload && action.payload.nextLocalID && action.payload.nextLocalID > newCandidate ) { newCandidate = action.payload.nextLocalID; } } else if ( action.type === sendTextMessageActionTypes.started || action.type === sendMultimediaMessageActionTypes.started || action.type === createLocalEntryActionType || action.type === createLocalMessageActionType ) { const { localID } = action.payload; invariant(localID, 'should be set'); newCandidate = numberFromLocalID(localID) + 1; } return newCandidate && newCandidate > state ? newCandidate : state; } diff --git a/lib/reducers/master-reducer.js b/lib/reducers/master-reducer.js index 45b03754b..aac3e45db 100644 --- a/lib/reducers/master-reducer.js +++ b/lib/reducers/master-reducer.js @@ -1,78 +1,78 @@ // @flow -import type { BaseAppState, BaseAction } from '../types/redux-types'; import type { BaseNavInfo } from '../types/nav-types'; +import type { BaseAppState, BaseAction } from '../types/redux-types'; import { fullStateSyncActionType, incrementalStateSyncActionType, } from '../types/socket-types'; -import { reduceLoadingStatuses } from './loading-reducer'; -import { reduceEntryInfos } from './entry-reducer'; -import { reduceCurrentUserInfo, reduceUserInfos } from './user-reducer'; -import reduceThreadInfos from './thread-reducer'; -import reduceBaseNavInfo from './nav-reducer'; -import { reduceMessageStore } from './message-reducer'; -import reduceUpdatesCurrentAsOf from './updates-reducer'; -import reduceURLPrefix from './url-prefix-reducer'; import reduceCalendarFilters from './calendar-filters-reducer'; import reduceConnectionInfo from './connection-reducer'; +import reduceDataLoaded from './data-loaded-reducer'; +import { reduceEntryInfos } from './entry-reducer'; import reduceForeground from './foreground-reducer'; +import { reduceLoadingStatuses } from './loading-reducer'; import reduceNextLocalID from './local-id-reducer'; +import { reduceMessageStore } from './message-reducer'; +import reduceBaseNavInfo from './nav-reducer'; import reduceQueuedReports from './report-reducer'; -import reduceDataLoaded from './data-loaded-reducer'; +import reduceThreadInfos from './thread-reducer'; +import reduceUpdatesCurrentAsOf from './updates-reducer'; +import reduceURLPrefix from './url-prefix-reducer'; +import { reduceCurrentUserInfo, reduceUserInfos } from './user-reducer'; export default function baseReducer>( state: T, action: BaseAction, ): T { const threadStore = reduceThreadInfos(state.threadStore, action); const { threadInfos } = threadStore; // Only allow checkpoints to increase if we are connected // or if the action is a STATE_SYNC let messageStore = reduceMessageStore( state.messageStore, action, threadInfos, ); let updatesCurrentAsOf = reduceUpdatesCurrentAsOf( state.updatesCurrentAsOf, action, ); const connection = reduceConnectionInfo(state.connection, action); if ( connection.status !== 'connected' && action.type !== incrementalStateSyncActionType && action.type !== fullStateSyncActionType ) { if (messageStore.currentAsOf !== state.messageStore.currentAsOf) { messageStore = { ...messageStore, currentAsOf: state.messageStore.currentAsOf, }; } if (updatesCurrentAsOf !== state.updatesCurrentAsOf) { updatesCurrentAsOf = state.updatesCurrentAsOf; } } return { ...state, navInfo: reduceBaseNavInfo(state.navInfo, action), entryStore: reduceEntryInfos(state.entryStore, action, threadInfos), loadingStatuses: reduceLoadingStatuses(state.loadingStatuses, action), currentUserInfo: reduceCurrentUserInfo(state.currentUserInfo, action), threadStore, userStore: reduceUserInfos(state.userStore, action), messageStore: reduceMessageStore(state.messageStore, action, threadInfos), updatesCurrentAsOf, urlPrefix: reduceURLPrefix(state.urlPrefix, action), calendarFilters: reduceCalendarFilters(state.calendarFilters, action), connection, foreground: reduceForeground(state.foreground, action), nextLocalID: reduceNextLocalID(state.nextLocalID, action), queuedReports: reduceQueuedReports(state.queuedReports, action), dataLoaded: reduceDataLoaded(state.dataLoaded, action), }; } diff --git a/lib/reducers/message-reducer.js b/lib/reducers/message-reducer.js index 309e800c3..6e36afbd1 100644 --- a/lib/reducers/message-reducer.js +++ b/lib/reducers/message-reducer.js @@ -1,910 +1,910 @@ // @flow -import { - type RawMessageInfo, - type LocalMessageInfo, - type MessageStore, - type MessageTruncationStatus, - type MessagesResponse, - type RawMediaMessageInfo, - type RawImagesMessageInfo, - messageTruncationStatus, - messageTypes, - defaultNumberPerThread, -} from '../types/message-types'; -import { type BaseAction, rehydrateActionType } from '../types/redux-types'; -import { type RawThreadInfo, threadPermissions } from '../types/thread-types'; -import { - updateTypes, - type UpdateInfo, - processUpdatesActionType, -} from '../types/update-types'; -import { - fullStateSyncActionType, - incrementalStateSyncActionType, -} from '../types/socket-types'; - import invariant from 'invariant'; +import _difference from 'lodash/fp/difference'; import _flow from 'lodash/fp/flow'; -import _map from 'lodash/fp/map'; -import _mapValues from 'lodash/fp/mapValues'; -const _mapValuesWithKeys = _mapValues.convert({ cap: false }); import _isEqual from 'lodash/fp/isEqual'; import _keyBy from 'lodash/fp/keyBy'; -import _orderBy from 'lodash/fp/orderBy'; -import _difference from 'lodash/fp/difference'; +import _map from 'lodash/fp/map'; +import _mapKeys from 'lodash/fp/mapKeys'; +import _mapValues from 'lodash/fp/mapValues'; import _omit from 'lodash/fp/omit'; +import _omitBy from 'lodash/fp/omitBy'; +import _orderBy from 'lodash/fp/orderBy'; import _pick from 'lodash/fp/pick'; import _pickBy from 'lodash/fp/pickBy'; -import _omitBy from 'lodash/fp/omitBy'; -import _mapKeys from 'lodash/fp/mapKeys'; import _uniq from 'lodash/fp/uniq'; -import { messageID, combineTruncationStatuses } from '../shared/message-utils'; -import { threadHasPermission, threadInChatList } from '../shared/thread-utils'; -import { setNewSessionActionType } from '../utils/action-utils'; -import { - logOutActionTypes, - deleteAccountActionTypes, - logInActionTypes, - resetPasswordActionTypes, - registerActionTypes, -} from '../actions/user-actions'; import { createEntryActionTypes, saveEntryActionTypes, deleteEntryActionTypes, restoreEntryActionTypes, } from '../actions/entry-actions'; -import { - changeThreadSettingsActionTypes, - deleteThreadActionTypes, - leaveThreadActionTypes, - newThreadActionTypes, - removeUsersFromThreadActionTypes, - changeThreadMemberRolesActionTypes, - joinThreadActionTypes, -} from '../actions/thread-actions'; import { fetchMessagesBeforeCursorActionTypes, fetchMostRecentMessagesActionTypes, sendTextMessageActionTypes, sendMultimediaMessageActionTypes, saveMessagesActionType, processMessagesActionType, messageStorePruneActionType, createLocalMessageActionType, } from '../actions/message-actions'; -import threadWatcher from '../shared/thread-watcher'; +import { + changeThreadSettingsActionTypes, + deleteThreadActionTypes, + leaveThreadActionTypes, + newThreadActionTypes, + removeUsersFromThreadActionTypes, + changeThreadMemberRolesActionTypes, + joinThreadActionTypes, +} from '../actions/thread-actions'; import { updateMultimediaMessageMediaActionType } from '../actions/upload-actions'; +import { + logOutActionTypes, + deleteAccountActionTypes, + logInActionTypes, + resetPasswordActionTypes, + registerActionTypes, +} from '../actions/user-actions'; +import { messageID, combineTruncationStatuses } from '../shared/message-utils'; +import { threadHasPermission, threadInChatList } from '../shared/thread-utils'; +import threadWatcher from '../shared/thread-watcher'; import { unshimMessageInfos } from '../shared/unshim-utils'; +import { + type RawMessageInfo, + type LocalMessageInfo, + type MessageStore, + type MessageTruncationStatus, + type MessagesResponse, + type RawMediaMessageInfo, + type RawImagesMessageInfo, + messageTruncationStatus, + messageTypes, + defaultNumberPerThread, +} from '../types/message-types'; +import { type BaseAction, rehydrateActionType } from '../types/redux-types'; +import { + fullStateSyncActionType, + incrementalStateSyncActionType, +} from '../types/socket-types'; +import { type RawThreadInfo, threadPermissions } from '../types/thread-types'; +import { + updateTypes, + type UpdateInfo, + processUpdatesActionType, +} from '../types/update-types'; +import { setNewSessionActionType } from '../utils/action-utils'; + +const _mapValuesWithKeys = _mapValues.convert({ cap: false }); // Input must already be ordered! function threadsToMessageIDsFromMessageInfos( orderedMessageInfos: RawMessageInfo[], ): { [threadID: string]: string[] } { const threads: { [threadID: string]: string[] } = {}; for (let messageInfo of orderedMessageInfos) { const key = messageID(messageInfo); if (!threads[messageInfo.threadID]) { threads[messageInfo.threadID] = [key]; } else { threads[messageInfo.threadID].push(key); } } return threads; } function threadIsWatched( threadInfo: ?RawThreadInfo, watchedIDs: $ReadOnlyArray, ) { return ( threadInfo && threadHasPermission(threadInfo, threadPermissions.VISIBLE) && (threadInChatList(threadInfo) || watchedIDs.includes(threadInfo.id)) ); } function freshMessageStore( messageInfos: RawMessageInfo[], truncationStatus: { [threadID: string]: MessageTruncationStatus }, currentAsOf: number, threadInfos: { [threadID: string]: RawThreadInfo }, ): MessageStore { const unshimmed = unshimMessageInfos(messageInfos); const orderedMessageInfos = _orderBy('time')('desc')(unshimmed); const messages = _keyBy(messageID)(orderedMessageInfos); const threadsToMessageIDs = threadsToMessageIDsFromMessageInfos( orderedMessageInfos, ); const lastPruned = Date.now(); const threads = _mapValuesWithKeys( (messageIDs: string[], threadID: string) => ({ messageIDs, startReached: truncationStatus[threadID] === messageTruncationStatus.EXHAUSTIVE, lastNavigatedTo: 0, lastPruned, }), )(threadsToMessageIDs); const watchedIDs = threadWatcher.getWatchedIDs(); for (let threadID in threadInfos) { const threadInfo = threadInfos[threadID]; if (threads[threadID] || !threadIsWatched(threadInfo, watchedIDs)) { continue; } threads[threadID] = { messageIDs: [], // We can conclude that startReached, since no messages were returned startReached: true, lastNavigatedTo: 0, lastPruned, }; } return { messages, threads, local: {}, currentAsOf }; } // oldMessageStore is from the old state // newMessageInfos, truncationStatus come from server function mergeNewMessages( oldMessageStore: MessageStore, newMessageInfos: $ReadOnlyArray, truncationStatus: { [threadID: string]: MessageTruncationStatus }, threadInfos: { [threadID: string]: RawThreadInfo }, actionType: *, ): MessageStore { const unshimmed = unshimMessageInfos(newMessageInfos); const localIDsToServerIDs: Map = new Map(); const orderedNewMessageInfos = _flow( _map((messageInfo: RawMessageInfo) => { const { id: inputID } = messageInfo; invariant(inputID, 'new messageInfos should have serverID'); const currentMessageInfo = oldMessageStore.messages[inputID]; if ( messageInfo.type === messageTypes.TEXT || messageInfo.type === messageTypes.IMAGES || messageInfo.type === messageTypes.MULTIMEDIA ) { const { localID: inputLocalID } = messageInfo; const currentLocalMessageInfo = inputLocalID ? oldMessageStore.messages[inputLocalID] : null; if (currentMessageInfo && currentMessageInfo.localID) { // If the client already has a RawMessageInfo with this serverID, keep // any localID associated with the existing one. This is because we // use localIDs as React keys and changing React keys leads to loss of // component state. (The conditional below is for Flow) if (messageInfo.type === messageTypes.TEXT) { messageInfo = { ...messageInfo, localID: currentMessageInfo.localID, }; } else if (messageInfo.type === messageTypes.MULTIMEDIA) { messageInfo = ({ ...messageInfo, localID: currentMessageInfo.localID, }: RawMediaMessageInfo); } else { messageInfo = ({ ...messageInfo, localID: currentMessageInfo.localID, }: RawImagesMessageInfo); } } else if (currentLocalMessageInfo && currentLocalMessageInfo.localID) { // If the client has a RawMessageInfo with this localID, but not with // the serverID, that means the message creation succeeded but the // success action never got processed. We set a key in // localIDsToServerIDs here to fix the messageIDs for the rest of the // MessageStore too. (The conditional below is for Flow) invariant(inputLocalID, 'should be set'); localIDsToServerIDs.set(inputLocalID, inputID); if (messageInfo.type === messageTypes.TEXT) { messageInfo = { ...messageInfo, localID: currentLocalMessageInfo.localID, }; } else if (messageInfo.type === messageTypes.MULTIMEDIA) { messageInfo = ({ ...messageInfo, localID: currentLocalMessageInfo.localID, }: RawMediaMessageInfo); } else { messageInfo = ({ ...messageInfo, localID: currentLocalMessageInfo.localID, }: RawImagesMessageInfo); } } else { // If neither the serverID nor the localID from the delivered // RawMessageInfo exists in the client store, then this message is // brand new to us. Ignore any localID provided by the server. // (The conditional below is for Flow) const { localID, ...rest } = messageInfo; if (rest.type === messageTypes.TEXT) { messageInfo = { ...rest }; } else if (rest.type === messageTypes.MULTIMEDIA) { messageInfo = ({ ...rest }: RawMediaMessageInfo); } else { messageInfo = ({ ...rest }: RawImagesMessageInfo); } } } return _isEqual(messageInfo)(currentMessageInfo) ? currentMessageInfo : messageInfo; }), _orderBy('time')('desc'), )(unshimmed); const threadsToMessageIDs = threadsToMessageIDsFromMessageInfos( orderedNewMessageInfos, ); const oldMessageInfosToCombine = []; const mustResortThreadMessageIDs = []; const lastPruned = Date.now(); const watchedIDs = threadWatcher.getWatchedIDs(); const local = {}; const threads = _flow( _pickBy((messageIDs: string[], threadID: string) => threadIsWatched(threadInfos[threadID], watchedIDs), ), _mapValuesWithKeys((messageIDs: string[], threadID: string) => { const oldThread = oldMessageStore.threads[threadID]; const truncate = truncationStatus[threadID]; if (!oldThread) { if (actionType === fetchMessagesBeforeCursorActionTypes.success) { // Well, this is weird. Somehow fetchMessagesBeforeCursor got called // for a thread that doesn't exist in the messageStore. How did this // happen? How do we even know what cursor to use if we didn't have // any messages? Anyways, the messageStore is predicated on the // principle that it is current. We can't create a ThreadMessageInfo // for a thread if we can't guarantee this, as the client has no UX // for endReached, only for startReached. We'll have to bail out here. return null; } return { messageIDs, startReached: truncate === messageTruncationStatus.EXHAUSTIVE, lastNavigatedTo: 0, lastPruned, }; } let oldMessageIDsUnchanged = true; const oldMessageIDs = oldThread.messageIDs.map((oldID) => { const newID = localIDsToServerIDs.get(oldID); if (newID !== null && newID !== undefined) { oldMessageIDsUnchanged = false; return newID; } return oldID; }); if (truncate === messageTruncationStatus.TRUNCATED) { // If the result set in the payload isn't contiguous with what we have // now, that means we need to dump what we have in the state and replace // it with the result set. We do this to achieve our two goals for the // messageStore: currentness and contiguousness. return { messageIDs, startReached: false, lastNavigatedTo: oldThread.lastNavigatedTo, lastPruned: oldThread.lastPruned, }; } const oldNotInNew = _difference(oldMessageIDs)(messageIDs); for (let id of oldNotInNew) { const oldMessageInfo = oldMessageStore.messages[id]; invariant(oldMessageInfo, `could not find ${id} in messageStore`); oldMessageInfosToCombine.push(oldMessageInfo); const localInfo = oldMessageStore.local[id]; if (localInfo) { local[id] = localInfo; } } const startReached = oldThread.startReached || truncate === messageTruncationStatus.EXHAUSTIVE; if (_difference(messageIDs)(oldMessageIDs).length === 0) { if (startReached === oldThread.startReached && oldMessageIDsUnchanged) { return oldThread; } return { messageIDs: oldMessageIDs, startReached, lastNavigatedTo: oldThread.lastNavigatedTo, lastPruned: oldThread.lastPruned, }; } const mergedMessageIDs = [...messageIDs, ...oldNotInNew]; mustResortThreadMessageIDs.push(threadID); return { messageIDs: mergedMessageIDs, startReached, lastNavigatedTo: oldThread.lastNavigatedTo, lastPruned: oldThread.lastPruned, }; }), _pickBy((thread) => !!thread), )(threadsToMessageIDs); for (let threadID in oldMessageStore.threads) { if ( threads[threadID] || !threadIsWatched(threadInfos[threadID], watchedIDs) ) { continue; } let thread = oldMessageStore.threads[threadID]; const truncate = truncationStatus[threadID]; if (truncate === messageTruncationStatus.EXHAUSTIVE) { thread = { ...thread, startReached: true, }; } threads[threadID] = thread; for (let id of thread.messageIDs) { const messageInfo = oldMessageStore.messages[id]; if (messageInfo) { oldMessageInfosToCombine.push(messageInfo); } const localInfo = oldMessageStore.local[id]; if (localInfo) { local[id] = localInfo; } } } for (let threadID in threadInfos) { const threadInfo = threadInfos[threadID]; if (threads[threadID] || !threadIsWatched(threadInfo, watchedIDs)) { continue; } threads[threadID] = { messageIDs: [], // We can conclude that startReached, since no messages were returned startReached: true, lastNavigatedTo: 0, lastPruned, }; } const messages = _flow( _orderBy('time')('desc'), _keyBy(messageID), )([...orderedNewMessageInfos, ...oldMessageInfosToCombine]); for (let threadID of mustResortThreadMessageIDs) { threads[threadID].messageIDs = _orderBy([ (id: string) => messages[id].time, ])('desc')(threads[threadID].messageIDs); } const currentAsOf = Math.max( orderedNewMessageInfos.length > 0 ? orderedNewMessageInfos[0].time : 0, oldMessageStore.currentAsOf, ); return { messages, threads, local, currentAsOf }; } function filterByNewThreadInfos( messageStore: MessageStore, threadInfos: { [id: string]: RawThreadInfo }, ): MessageStore { const watchedIDs = threadWatcher.getWatchedIDs(); const watchedThreadInfos = _pickBy((threadInfo: RawThreadInfo) => threadIsWatched(threadInfo, watchedIDs), )(threadInfos); const messageIDsToRemove = []; for (let threadID in messageStore.threads) { if (watchedThreadInfos[threadID]) { continue; } for (let id of messageStore.threads[threadID].messageIDs) { messageIDsToRemove.push(id); } } return { messages: _omit(messageIDsToRemove)(messageStore.messages), threads: _pick(Object.keys(watchedThreadInfos))(messageStore.threads), local: _omit(messageIDsToRemove)(messageStore.local), currentAsOf: messageStore.currentAsOf, }; } function reduceMessageStore( messageStore: MessageStore, action: BaseAction, newThreadInfos: { [id: string]: RawThreadInfo }, ): MessageStore { if ( action.type === logInActionTypes.success || action.type === resetPasswordActionTypes.success ) { const messagesResult = action.payload.messagesResult; return freshMessageStore( messagesResult.messageInfos, messagesResult.truncationStatus, messagesResult.currentAsOf, newThreadInfos, ); } else if (action.type === incrementalStateSyncActionType) { if ( action.payload.messagesResult.rawMessageInfos.length === 0 && action.payload.updatesResult.newUpdates.length === 0 ) { return messageStore; } const messagesResult = mergeUpdatesIntoMessagesResult( action.payload.messagesResult, action.payload.updatesResult.newUpdates, ); return mergeNewMessages( messageStore, messagesResult.rawMessageInfos, messagesResult.truncationStatuses, newThreadInfos, action.type, ); } else if (action.type === processUpdatesActionType) { if (action.payload.updatesResult.newUpdates.length === 0) { return messageStore; } const mergedMessageInfos = []; const mergedTruncationStatuses = {}; const { newUpdates } = action.payload.updatesResult; for (let updateInfo of newUpdates) { if (updateInfo.type !== updateTypes.JOIN_THREAD) { continue; } for (let messageInfo of updateInfo.rawMessageInfos) { mergedMessageInfos.push(messageInfo); } mergedTruncationStatuses[ updateInfo.threadInfo.id ] = combineTruncationStatuses( updateInfo.truncationStatus, mergedTruncationStatuses[updateInfo.threadInfo.id], ); } if (Object.keys(mergedTruncationStatuses).length === 0) { return messageStore; } const newMessageStore = mergeNewMessages( messageStore, mergedMessageInfos, mergedTruncationStatuses, newThreadInfos, action.type, ); return { messages: newMessageStore.messages, threads: newMessageStore.threads, local: newMessageStore.local, currentAsOf: messageStore.currentAsOf, }; } else if ( action.type === fullStateSyncActionType || action.type === processMessagesActionType ) { const { messagesResult } = action.payload; return mergeNewMessages( messageStore, messagesResult.rawMessageInfos, messagesResult.truncationStatuses, newThreadInfos, action.type, ); } else if ( action.type === fetchMessagesBeforeCursorActionTypes.success || action.type === fetchMostRecentMessagesActionTypes.success ) { return mergeNewMessages( messageStore, action.payload.rawMessageInfos, { [action.payload.threadID]: action.payload.truncationStatus }, newThreadInfos, action.type, ); } else if ( action.type === logOutActionTypes.success || action.type === deleteAccountActionTypes.success || action.type === deleteThreadActionTypes.success || action.type === leaveThreadActionTypes.success || action.type === setNewSessionActionType ) { return filterByNewThreadInfos(messageStore, newThreadInfos); } else if (action.type === newThreadActionTypes.success) { const { newThreadID } = action.payload; const truncationStatuses = {}; for (let messageInfo of action.payload.newMessageInfos) { truncationStatuses[messageInfo.threadID] = messageInfo.threadID === newThreadID ? messageTruncationStatus.EXHAUSTIVE : messageTruncationStatus.UNCHANGED; } return mergeNewMessages( messageStore, action.payload.newMessageInfos, truncationStatuses, newThreadInfos, action.type, ); } else if (action.type === registerActionTypes.success) { const truncationStatuses = {}; for (let messageInfo of action.payload.rawMessageInfos) { truncationStatuses[messageInfo.threadID] = messageTruncationStatus.EXHAUSTIVE; } return mergeNewMessages( messageStore, action.payload.rawMessageInfos, truncationStatuses, newThreadInfos, action.type, ); } else if ( action.type === changeThreadSettingsActionTypes.success || action.type === removeUsersFromThreadActionTypes.success || action.type === changeThreadMemberRolesActionTypes.success ) { return mergeNewMessages( messageStore, action.payload.newMessageInfos, { [action.payload.threadID]: messageTruncationStatus.UNCHANGED }, newThreadInfos, action.type, ); } else if ( action.type === createEntryActionTypes.success || action.type === saveEntryActionTypes.success ) { return mergeNewMessages( messageStore, action.payload.newMessageInfos, { [action.payload.threadID]: messageTruncationStatus.UNCHANGED }, newThreadInfos, action.type, ); } else if (action.type === deleteEntryActionTypes.success) { const payload = action.payload; if (payload) { return mergeNewMessages( messageStore, payload.newMessageInfos, { [payload.threadID]: messageTruncationStatus.UNCHANGED }, newThreadInfos, action.type, ); } } else if (action.type === restoreEntryActionTypes.success) { const { threadID } = action.payload; return mergeNewMessages( messageStore, action.payload.newMessageInfos, { [threadID]: messageTruncationStatus.UNCHANGED }, newThreadInfos, action.type, ); } else if (action.type === joinThreadActionTypes.success) { return mergeNewMessages( messageStore, action.payload.rawMessageInfos, action.payload.truncationStatuses, newThreadInfos, action.type, ); } else if ( action.type === sendTextMessageActionTypes.started || action.type === sendMultimediaMessageActionTypes.started ) { const { payload } = action; const { localID, threadID } = payload; invariant(localID, `localID should be set on ${action.type}`); if (messageStore.messages[localID]) { const messages = { ...messageStore.messages, [localID]: payload }; const local = _pickBy( (localInfo: LocalMessageInfo, key: string) => key !== localID, )(messageStore.local); const threads = { ...messageStore.threads, [threadID]: { ...messageStore.threads[threadID], messageIDs: _orderBy([(id: string) => messages[id].time])('desc')( messageStore.threads[threadID].messageIDs, ), }, }; return { ...messageStore, messages, threads, local }; } const { messageIDs } = messageStore.threads[threadID]; for (let existingMessageID of messageIDs) { const existingMessageInfo = messageStore.messages[existingMessageID]; if (existingMessageInfo && existingMessageInfo.localID === localID) { return messageStore; } } return { messages: { ...messageStore.messages, [localID]: payload, }, threads: { ...messageStore.threads, [threadID]: { ...messageStore.threads[threadID], messageIDs: [localID, ...messageStore.threads[threadID].messageIDs], }, }, local: messageStore.local, currentAsOf: messageStore.currentAsOf, }; } else if ( action.type === sendTextMessageActionTypes.failed || action.type === sendMultimediaMessageActionTypes.failed ) { const { localID } = action.payload; return { messages: messageStore.messages, threads: messageStore.threads, local: { ...messageStore.local, [localID]: { sendFailed: true }, }, currentAsOf: messageStore.currentAsOf, }; } else if ( action.type === sendTextMessageActionTypes.success || action.type === sendMultimediaMessageActionTypes.success ) { const { payload } = action; const replaceMessageKey = (messageKey: string) => messageKey === payload.localID ? payload.serverID : messageKey; let newMessages; if (messageStore.messages[payload.serverID]) { // If somehow the serverID got in there already, we'll just update the // serverID message and scrub the localID one newMessages = _omitBy( (messageInfo: RawMessageInfo) => messageInfo.type === messageTypes.TEXT && !messageInfo.id && messageInfo.localID === payload.localID, )(messageStore.messages); } else if (messageStore.messages[payload.localID]) { // The normal case, the localID message gets replaced by the serverID one newMessages = _mapKeys(replaceMessageKey)(messageStore.messages); } else { // Well this is weird, we probably got deauthorized between when the // action was dispatched and when we ran this reducer... return messageStore; } newMessages[payload.serverID] = { ...newMessages[payload.serverID], id: payload.serverID, localID: payload.localID, time: payload.time, }; const threadID = payload.threadID; const newMessageIDs = _flow( _uniq, _orderBy([(id: string) => newMessages[id].time])('desc'), )(messageStore.threads[threadID].messageIDs.map(replaceMessageKey)); const currentAsOf = Math.max(payload.time, messageStore.currentAsOf); const local = _pickBy( (localInfo: LocalMessageInfo, key: string) => key !== payload.localID, )(messageStore.local); return { messages: newMessages, threads: { ...messageStore.threads, [threadID]: { ...messageStore.threads[threadID], messageIDs: newMessageIDs, }, }, local, currentAsOf, }; } else if (action.type === saveMessagesActionType) { const truncationStatuses = {}; for (let messageInfo of action.payload.rawMessageInfos) { truncationStatuses[messageInfo.threadID] = messageTruncationStatus.UNCHANGED; } const newMessageStore = mergeNewMessages( messageStore, action.payload.rawMessageInfos, truncationStatuses, newThreadInfos, action.type, ); return { messages: newMessageStore.messages, threads: newMessageStore.threads, local: newMessageStore.local, // We avoid bumping currentAsOf because notifs may include a contracted // RawMessageInfo, so we want to make sure we still fetch it currentAsOf: messageStore.currentAsOf, }; } else if (action.type === messageStorePruneActionType) { const now = Date.now(); const messageIDsToPrune = []; let newThreads = { ...messageStore.threads }; for (let threadID of action.payload.threadIDs) { const thread = newThreads[threadID]; if (!thread) { continue; } const removed = thread.messageIDs.splice(defaultNumberPerThread); for (let id of removed) { messageIDsToPrune.push(id); } thread.lastPruned = now; if (removed.length > 0) { thread.startReached = false; } } return { messages: _omit(messageIDsToPrune)(messageStore.messages), threads: newThreads, local: _omit(messageIDsToPrune)(messageStore.local), currentAsOf: messageStore.currentAsOf, }; } else if (action.type === updateMultimediaMessageMediaActionType) { const { messageID: id, currentMediaID, mediaUpdate } = action.payload; const message = messageStore.messages[id]; invariant(message, `message with ID ${id} could not be found`); invariant( message.type === messageTypes.IMAGES || message.type === messageTypes.MULTIMEDIA, `message with ID ${id} is not multimedia`, ); let replaced = false; const media = []; for (let singleMedia of message.media) { if (singleMedia.id !== currentMediaID) { media.push(singleMedia); } else if (singleMedia.type === 'photo') { replaced = true; invariant( mediaUpdate.type === 'photo', 'media with matching IDs should have same media types', ); media.push({ ...singleMedia, ...mediaUpdate, }); } else if (singleMedia.type === 'video') { replaced = true; invariant( mediaUpdate.type === 'video', 'media with matching IDs should have same media types', ); media.push({ ...singleMedia, ...mediaUpdate, }); } } invariant( replaced, `message ${id} did not contain media with ID ${currentMediaID}`, ); return { ...messageStore, messages: { ...messageStore.messages, [id]: { ...message, media, }, }, }; } else if (action.type === createLocalMessageActionType) { const messageInfo = action.payload; return { ...messageStore, messages: { ...messageStore.messages, [messageInfo.localID]: messageInfo, }, threads: { ...messageStore.threads, [messageInfo.threadID]: { ...messageStore.threads[messageInfo.threadID], messageIDs: [ messageInfo.localID, ...messageStore.threads[messageInfo.threadID].messageIDs, ], }, }, }; } else if (action.type === rehydrateActionType) { // When starting the app on native, we filter out any local-only multimedia // messages because the relevant context is no longer available const { messages, threads, local } = messageStore; const newMessages = {}; let newThreads = threads, newLocal = local; for (let id in messages) { const message = messages[id]; if ( (message.type !== messageTypes.IMAGES && message.type !== messageTypes.MULTIMEDIA) || message.id ) { newMessages[id] = message; continue; } const { threadID } = message; newThreads = { ...newThreads, [threadID]: { ...newThreads[threadID], messageIDs: newThreads[threadID].messageIDs.filter( (curMessageID) => curMessageID !== id, ), }, }; newLocal = _pickBy( (localInfo: LocalMessageInfo, key: string) => key !== id, )(newLocal); } if (newThreads === threads) { return messageStore; } return { ...messageStore, messages: newMessages, threads: newThreads, local: newLocal, }; } return messageStore; } function mergeUpdatesIntoMessagesResult( messagesResult: MessagesResponse, newUpdates: $ReadOnlyArray, ): MessagesResponse { const messageIDs = new Set( messagesResult.rawMessageInfos.map((messageInfo) => messageInfo.id), ); const mergedMessageInfos = [...messagesResult.rawMessageInfos]; const mergedTruncationStatuses = { ...messagesResult.truncationStatuses }; for (let updateInfo of newUpdates) { if (updateInfo.type !== updateTypes.JOIN_THREAD) { continue; } for (let messageInfo of updateInfo.rawMessageInfos) { if (messageIDs.has(messageInfo.id)) { continue; } mergedMessageInfos.push(messageInfo); messageIDs.add(messageInfo.id); } mergedTruncationStatuses[ updateInfo.threadInfo.id ] = combineTruncationStatuses( updateInfo.truncationStatus, mergedTruncationStatuses[updateInfo.threadInfo.id], ); } return { rawMessageInfos: mergedMessageInfos, truncationStatuses: mergedTruncationStatuses, currentAsOf: messagesResult.currentAsOf, }; } export { freshMessageStore, reduceMessageStore }; diff --git a/lib/reducers/nav-reducer.js b/lib/reducers/nav-reducer.js index 67c78ea64..91bdc953c 100644 --- a/lib/reducers/nav-reducer.js +++ b/lib/reducers/nav-reducer.js @@ -1,45 +1,44 @@ // @flow -import type { BaseAction } from '../types/redux-types'; -import type { BaseNavInfo } from '../types/nav-types'; -import { - fullStateSyncActionType, - incrementalStateSyncActionType, -} from '../types/socket-types'; - import { updateCalendarQueryActionTypes } from '../actions/entry-actions'; import { logInActionTypes, resetPasswordActionTypes, registerActionTypes, } from '../actions/user-actions'; +import type { BaseNavInfo } from '../types/nav-types'; +import type { BaseAction } from '../types/redux-types'; +import { + fullStateSyncActionType, + incrementalStateSyncActionType, +} from '../types/socket-types'; export default function reduceBaseNavInfo( state: T, action: BaseAction, ): T { if ( action.type === logInActionTypes.started || action.type === resetPasswordActionTypes.started || action.type === registerActionTypes.started || action.type === fullStateSyncActionType || action.type === incrementalStateSyncActionType ) { const { startDate, endDate } = action.payload.calendarQuery; return { ...state, startDate, endDate }; } else if ( action.type === updateCalendarQueryActionTypes.started && action.payload && action.payload.calendarQuery ) { const { startDate, endDate } = action.payload.calendarQuery; return { ...state, startDate, endDate }; } else if ( action.type === updateCalendarQueryActionTypes.success && !action.payload.calendarQueryAlreadyUpdated ) { const { startDate, endDate } = action.payload.calendarQuery; return { ...state, startDate, endDate }; } return state; } diff --git a/lib/reducers/report-reducer.js b/lib/reducers/report-reducer.js index ecf8d8c3e..966dd4402 100644 --- a/lib/reducers/report-reducer.js +++ b/lib/reducers/report-reducer.js @@ -1,34 +1,33 @@ // @flow -import type { BaseAction } from '../types/redux-types'; -import type { ClientReportCreationRequest } from '../types/report-types'; - import { sendReportActionTypes, sendReportsActionTypes, queueReportsActionType, } from '../actions/report-actions'; +import type { BaseAction } from '../types/redux-types'; +import type { ClientReportCreationRequest } from '../types/report-types'; export default function reduceQueuedReports( state: $ReadOnlyArray, action: BaseAction, ): $ReadOnlyArray { if ( (action.type === sendReportActionTypes.success || action.type === sendReportsActionTypes.success) && action.payload ) { const { payload } = action; const updatedReports = state.filter( (response) => !payload.reports.includes(response), ); if (updatedReports.length === state.length) { return state; } return updatedReports; } else if (action.type === queueReportsActionType) { const { reports } = action.payload; return [...state, ...reports]; } return state; } diff --git a/lib/reducers/thread-reducer.js b/lib/reducers/thread-reducer.js index 8b3c903ec..8b1197e58 100644 --- a/lib/reducers/thread-reducer.js +++ b/lib/reducers/thread-reducer.js @@ -1,343 +1,342 @@ // @flow -import type { BaseAction } from '../types/redux-types'; -import type { RawThreadInfo, ThreadStore } from '../types/thread-types'; -import { - updateTypes, - type UpdateInfo, - processUpdatesActionType, -} from '../types/update-types'; -import { - serverRequestTypes, - processServerRequestsActionType, -} from '../types/request-types'; -import { - fullStateSyncActionType, - incrementalStateSyncActionType, -} from '../types/socket-types'; +import _isEqual from 'lodash/fp/isEqual'; + import { setThreadUnreadStatusActionTypes, updateActivityActionTypes, } from '../actions/activity-actions'; +import { saveMessagesActionType } from '../actions/message-actions'; import { sendReportActionTypes, sendReportsActionTypes, } from '../actions/report-actions'; import { - type ClientThreadInconsistencyReportCreationRequest, - reportTypes, -} from '../types/report-types'; - -import _isEqual from 'lodash/fp/isEqual'; - -import { setNewSessionActionType } from '../utils/action-utils'; + changeThreadSettingsActionTypes, + deleteThreadActionTypes, + newThreadActionTypes, + removeUsersFromThreadActionTypes, + changeThreadMemberRolesActionTypes, + joinThreadActionTypes, + leaveThreadActionTypes, +} from '../actions/thread-actions'; import { logOutActionTypes, deleteAccountActionTypes, logInActionTypes, resetPasswordActionTypes, registerActionTypes, updateSubscriptionActionTypes, } from '../actions/user-actions'; +import type { BaseAction } from '../types/redux-types'; import { - changeThreadSettingsActionTypes, - deleteThreadActionTypes, - newThreadActionTypes, - removeUsersFromThreadActionTypes, - changeThreadMemberRolesActionTypes, - joinThreadActionTypes, - leaveThreadActionTypes, -} from '../actions/thread-actions'; -import { saveMessagesActionType } from '../actions/message-actions'; -import { getConfig } from '../utils/config'; + type ClientThreadInconsistencyReportCreationRequest, + reportTypes, +} from '../types/report-types'; +import { + serverRequestTypes, + processServerRequestsActionType, +} from '../types/request-types'; +import { + fullStateSyncActionType, + incrementalStateSyncActionType, +} from '../types/socket-types'; +import type { RawThreadInfo, ThreadStore } from '../types/thread-types'; +import { + updateTypes, + type UpdateInfo, + processUpdatesActionType, +} from '../types/update-types'; import { actionLogger } from '../utils/action-logger'; +import { setNewSessionActionType } from '../utils/action-utils'; +import { getConfig } from '../utils/config'; import { sanitizeAction } from '../utils/sanitization'; function reduceThreadUpdates( threadInfos: { [id: string]: RawThreadInfo }, payload: { +updatesResult: { newUpdates: $ReadOnlyArray } }, ): { [id: string]: RawThreadInfo } { const newState = { ...threadInfos }; let someThreadUpdated = false; for (let update of payload.updatesResult.newUpdates) { if ( (update.type === updateTypes.UPDATE_THREAD || update.type === updateTypes.JOIN_THREAD) && !_isEqual(threadInfos[update.threadInfo.id])(update.threadInfo) ) { someThreadUpdated = true; newState[update.threadInfo.id] = update.threadInfo; } else if ( update.type === updateTypes.UPDATE_THREAD_READ_STATUS && threadInfos[update.threadID] && threadInfos[update.threadID].currentUser.unread !== update.unread ) { someThreadUpdated = true; newState[update.threadID] = { ...threadInfos[update.threadID], currentUser: { ...threadInfos[update.threadID].currentUser, unread: update.unread, }, }; } else if ( update.type === updateTypes.DELETE_THREAD && threadInfos[update.threadID] ) { someThreadUpdated = true; delete newState[update.threadID]; } else if (update.type === updateTypes.DELETE_ACCOUNT) { for (let threadID in threadInfos) { const threadInfo = threadInfos[threadID]; const newMembers = threadInfo.members.filter( (member) => member.id !== update.deletedUserID, ); if (newMembers.length < threadInfo.members.length) { someThreadUpdated = true; newState[threadID] = { ...threadInfo, members: newMembers, }; } } } } if (!someThreadUpdated) { return threadInfos; } return newState; } const emptyArray = []; function findInconsistencies( action: BaseAction, beforeStateCheck: { [id: string]: RawThreadInfo }, afterStateCheck: { [id: string]: RawThreadInfo }, ): ClientThreadInconsistencyReportCreationRequest[] { if (_isEqual(beforeStateCheck)(afterStateCheck)) { return emptyArray; } return [ { type: reportTypes.THREAD_INCONSISTENCY, platformDetails: getConfig().platformDetails, beforeAction: beforeStateCheck, action: sanitizeAction(action), pushResult: afterStateCheck, lastActions: actionLogger.interestingActionSummaries, time: Date.now(), }, ]; } export default function reduceThreadInfos( state: ThreadStore, action: BaseAction, ): ThreadStore { if ( action.type === logInActionTypes.success || action.type === registerActionTypes.success || action.type === resetPasswordActionTypes.success || action.type === fullStateSyncActionType ) { if (_isEqual(state.threadInfos)(action.payload.threadInfos)) { return state; } return { threadInfos: action.payload.threadInfos, inconsistencyReports: state.inconsistencyReports, }; } else if ( action.type === logOutActionTypes.success || action.type === deleteAccountActionTypes.success || (action.type === setNewSessionActionType && action.payload.sessionChange.cookieInvalidated) ) { if (Object.keys(state.threadInfos).length === 0) { return state; } return { threadInfos: {}, inconsistencyReports: state.inconsistencyReports, }; } else if ( action.type === joinThreadActionTypes.success || action.type === leaveThreadActionTypes.success || action.type === deleteThreadActionTypes.success || action.type === changeThreadSettingsActionTypes.success || action.type === removeUsersFromThreadActionTypes.success || action.type === changeThreadMemberRolesActionTypes.success || action.type === incrementalStateSyncActionType || action.type === processUpdatesActionType || action.type === newThreadActionTypes.success ) { if (action.payload.updatesResult.newUpdates.length === 0) { return state; } return { threadInfos: reduceThreadUpdates(state.threadInfos, action.payload), inconsistencyReports: state.inconsistencyReports, }; } else if (action.type === updateSubscriptionActionTypes.success) { const newThreadInfos = { ...state.threadInfos, [action.payload.threadID]: { ...state.threadInfos[action.payload.threadID], currentUser: { ...state.threadInfos[action.payload.threadID].currentUser, subscription: action.payload.subscription, }, }, }; return { threadInfos: newThreadInfos, inconsistencyReports: state.inconsistencyReports, }; } else if (action.type === saveMessagesActionType) { const threadIDToMostRecentTime = new Map(); for (let messageInfo of action.payload.rawMessageInfos) { const current = threadIDToMostRecentTime.get(messageInfo.threadID); if (!current || current < messageInfo.time) { threadIDToMostRecentTime.set(messageInfo.threadID, messageInfo.time); } } const changedThreadInfos = {}; for (let [threadID, mostRecentTime] of threadIDToMostRecentTime) { const threadInfo = state.threadInfos[threadID]; if ( !threadInfo || threadInfo.currentUser.unread || action.payload.updatesCurrentAsOf > mostRecentTime ) { continue; } changedThreadInfos[threadID] = { ...state.threadInfos[threadID], currentUser: { ...state.threadInfos[threadID].currentUser, unread: true, }, }; } if (Object.keys(changedThreadInfos).length !== 0) { return { threadInfos: { ...state.threadInfos, ...changedThreadInfos, }, inconsistencyReports: state.inconsistencyReports, }; } } else if ( (action.type === sendReportActionTypes.success || action.type === sendReportsActionTypes.success) && action.payload ) { const { payload } = action; const updatedReports = state.inconsistencyReports.filter( (response) => !payload.reports.includes(response), ); if (updatedReports.length === state.inconsistencyReports.length) { return state; } return { threadInfos: state.threadInfos, inconsistencyReports: updatedReports, }; } else if (action.type === processServerRequestsActionType) { const checkStateRequest = action.payload.serverRequests.find( (candidate) => candidate.type === serverRequestTypes.CHECK_STATE, ); if (!checkStateRequest || !checkStateRequest.stateChanges) { return state; } const { rawThreadInfos, deleteThreadIDs } = checkStateRequest.stateChanges; if (!rawThreadInfos && !deleteThreadIDs) { return state; } const newThreadInfos = { ...state.threadInfos }; if (rawThreadInfos) { for (let rawThreadInfo of rawThreadInfos) { newThreadInfos[rawThreadInfo.id] = rawThreadInfo; } } if (deleteThreadIDs) { for (let deleteThreadID of deleteThreadIDs) { delete newThreadInfos[deleteThreadID]; } } const newInconsistencies = findInconsistencies( action, state.threadInfos, newThreadInfos, ); return { threadInfos: newThreadInfos, inconsistencyReports: [ ...state.inconsistencyReports, ...newInconsistencies, ], }; } else if (action.type === updateActivityActionTypes.success) { const updatedThreadInfos = {}; for (let setToUnread of action.payload.result.unfocusedToUnread) { const threadInfo = state.threadInfos[setToUnread]; if (threadInfo && !threadInfo.currentUser.unread) { updatedThreadInfos[setToUnread] = { ...threadInfo, currentUser: { ...threadInfo.currentUser, unread: true, }, }; } } if (Object.keys(updatedThreadInfos).length === 0) { return state; } return { threadInfos: { ...state.threadInfos, ...updatedThreadInfos }, inconsistencyReports: state.inconsistencyReports, }; } else if (action.type === setThreadUnreadStatusActionTypes.started) { const { threadID, unread } = action.payload; return { ...state, threadInfos: { ...state.threadInfos, [threadID]: { ...state.threadInfos[threadID], currentUser: { ...state.threadInfos[threadID].currentUser, unread, }, }, }, }; } else if (action.type === setThreadUnreadStatusActionTypes.success) { const { threadID, resetToUnread } = action.payload; const currentUser = state.threadInfos[threadID].currentUser; if (!resetToUnread || currentUser.unread) { return state; } const updatedUser = { ...currentUser, unread: true, }; return { ...state, threadInfos: { ...state.threadInfos, [threadID]: { ...state.threadInfos[threadID], currentUser: updatedUser, }, }, }; } return state; } diff --git a/lib/reducers/updates-reducer.js b/lib/reducers/updates-reducer.js index 09d087b8d..863c182e9 100644 --- a/lib/reducers/updates-reducer.js +++ b/lib/reducers/updates-reducer.js @@ -1,34 +1,33 @@ // @flow -import type { BaseAction } from '../types/redux-types'; -import { processUpdatesActionType } from '../types/update-types'; - import { logInActionTypes, resetPasswordActionTypes, } from '../actions/user-actions'; +import type { BaseAction } from '../types/redux-types'; import { fullStateSyncActionType, incrementalStateSyncActionType, } from '../types/socket-types'; +import { processUpdatesActionType } from '../types/update-types'; function reduceUpdatesCurrentAsOf( currentAsOf: number, action: BaseAction, ): number { if ( action.type === logInActionTypes.success || action.type === resetPasswordActionTypes.success ) { return action.payload.updatesCurrentAsOf; } else if (action.type === fullStateSyncActionType) { return action.payload.updatesCurrentAsOf; } else if (action.type === incrementalStateSyncActionType) { return action.payload.updatesResult.currentAsOf; } else if (action.type === processUpdatesActionType) { return Math.max(action.payload.updatesResult.currentAsOf, currentAsOf); } return currentAsOf; } export default reduceUpdatesCurrentAsOf; diff --git a/lib/reducers/url-prefix-reducer.js b/lib/reducers/url-prefix-reducer.js index b2747b899..d999c4ce7 100644 --- a/lib/reducers/url-prefix-reducer.js +++ b/lib/reducers/url-prefix-reducer.js @@ -1,12 +1,11 @@ // @flow import type { BaseAction } from '../types/redux-types'; - import { setURLPrefix } from '../utils/url-utils'; export default function reduceURLPrefix(state: string, action: BaseAction) { if (action.type === setURLPrefix) { return action.payload; } return state; } diff --git a/lib/reducers/user-reducer.js b/lib/reducers/user-reducer.js index 090dd0dfa..8cf4dfb07 100644 --- a/lib/reducers/user-reducer.js +++ b/lib/reducers/user-reducer.js @@ -1,231 +1,230 @@ // @flow -import type { BaseAction } from '../types/redux-types'; -import type { - CurrentUserInfo, - UserStore, - UserInfos, -} from '../types/user-types'; -import { updateTypes, processUpdatesActionType } from '../types/update-types'; -import { - serverRequestTypes, - processServerRequestsActionType, -} from '../types/request-types'; -import { - fullStateSyncActionType, - incrementalStateSyncActionType, -} from '../types/socket-types'; -import { - type UserInconsistencyReportCreationRequest, - reportTypes, -} from '../types/report-types'; - import invariant from 'invariant'; -import _keyBy from 'lodash/fp/keyBy'; import _isEqual from 'lodash/fp/isEqual'; +import _keyBy from 'lodash/fp/keyBy'; -import { setNewSessionActionType } from '../utils/action-utils'; +import { joinThreadActionTypes } from '../actions/thread-actions'; import { logOutActionTypes, deleteAccountActionTypes, logInActionTypes, registerActionTypes, resetPasswordActionTypes, changeUserSettingsActionTypes, } from '../actions/user-actions'; -import { joinThreadActionTypes } from '../actions/thread-actions'; -import { getConfig } from '../utils/config'; +import type { BaseAction } from '../types/redux-types'; +import { + type UserInconsistencyReportCreationRequest, + reportTypes, +} from '../types/report-types'; +import { + serverRequestTypes, + processServerRequestsActionType, +} from '../types/request-types'; +import { + fullStateSyncActionType, + incrementalStateSyncActionType, +} from '../types/socket-types'; +import { updateTypes, processUpdatesActionType } from '../types/update-types'; +import type { + CurrentUserInfo, + UserStore, + UserInfos, +} from '../types/user-types'; import { actionLogger } from '../utils/action-logger'; +import { setNewSessionActionType } from '../utils/action-utils'; +import { getConfig } from '../utils/config'; import { sanitizeAction } from '../utils/sanitization'; function reduceCurrentUserInfo( state: ?CurrentUserInfo, action: BaseAction, ): ?CurrentUserInfo { if ( action.type === logInActionTypes.success || action.type === resetPasswordActionTypes.success || action.type === registerActionTypes.success || action.type === logOutActionTypes.success || action.type === deleteAccountActionTypes.success ) { if (!_isEqual(action.payload.currentUserInfo)(state)) { return action.payload.currentUserInfo; } } else if ( action.type === setNewSessionActionType && action.payload.sessionChange.currentUserInfo ) { const { sessionChange } = action.payload; if (!_isEqual(sessionChange.currentUserInfo)(state)) { return sessionChange.currentUserInfo; } } else if (action.type === fullStateSyncActionType) { const { currentUserInfo } = action.payload; if (!_isEqual(currentUserInfo)(state)) { return currentUserInfo; } } else if ( action.type === incrementalStateSyncActionType || action.type === processUpdatesActionType ) { for (let update of action.payload.updatesResult.newUpdates) { if ( update.type === updateTypes.UPDATE_CURRENT_USER && !_isEqual(update.currentUserInfo)(state) ) { return update.currentUserInfo; } } } else if (action.type === changeUserSettingsActionTypes.success) { invariant( state && !state.anonymous, "can't change settings if not logged in", ); const email = action.payload.email; if (!email) { return state; } return { id: state.id, username: state.username, email: email, emailVerified: false, }; } else if (action.type === processServerRequestsActionType) { const checkStateRequest = action.payload.serverRequests.find( (candidate) => candidate.type === serverRequestTypes.CHECK_STATE, ); if ( checkStateRequest && checkStateRequest.stateChanges && checkStateRequest.stateChanges.currentUserInfo && !_isEqual(checkStateRequest.stateChanges.currentUserInfo)(state) ) { return checkStateRequest.stateChanges.currentUserInfo; } } return state; } function findInconsistencies( action: BaseAction, beforeStateCheck: UserInfos, afterStateCheck: UserInfos, ): UserInconsistencyReportCreationRequest[] { if (_isEqual(beforeStateCheck)(afterStateCheck)) { return []; } return [ { type: reportTypes.USER_INCONSISTENCY, platformDetails: getConfig().platformDetails, action: sanitizeAction(action), beforeStateCheck, afterStateCheck, lastActions: actionLogger.interestingActionSummaries, time: Date.now(), }, ]; } function reduceUserInfos(state: UserStore, action: BaseAction): UserStore { if (action.type === joinThreadActionTypes.success) { const newUserInfos = _keyBy((userInfo) => userInfo.id)( action.payload.userInfos, ); const updated = { ...state.userInfos, ...newUserInfos }; if (!_isEqual(state.userInfos)(updated)) { return { userInfos: updated, inconsistencyReports: state.inconsistencyReports, }; } } else if ( action.type === logOutActionTypes.success || action.type === deleteAccountActionTypes.success || (action.type === setNewSessionActionType && action.payload.sessionChange.cookieInvalidated) ) { if (Object.keys(state.userInfos).length === 0) { return state; } return { userInfos: {}, inconsistencyReports: state.inconsistencyReports, }; } else if ( action.type === logInActionTypes.success || action.type === registerActionTypes.success || action.type === resetPasswordActionTypes.success || action.type === fullStateSyncActionType ) { const newUserInfos = _keyBy((userInfo) => userInfo.id)( action.payload.userInfos, ); if (!_isEqual(state.userInfos)(newUserInfos)) { return { userInfos: newUserInfos, inconsistencyReports: state.inconsistencyReports, }; } } else if ( action.type === incrementalStateSyncActionType || action.type === processUpdatesActionType ) { const newUserInfos = _keyBy((userInfo) => userInfo.id)( action.payload.userInfos, ); const updated = { ...state.userInfos, ...newUserInfos }; for (let update of action.payload.updatesResult.newUpdates) { if (update.type === updateTypes.DELETE_ACCOUNT) { delete updated[update.deletedUserID]; } } if (!_isEqual(state.userInfos)(updated)) { return { userInfos: updated, inconsistencyReports: state.inconsistencyReports, }; } } else if (action.type === processServerRequestsActionType) { const checkStateRequest = action.payload.serverRequests.find( (candidate) => candidate.type === serverRequestTypes.CHECK_STATE, ); if (!checkStateRequest || !checkStateRequest.stateChanges) { return state; } const { userInfos, deleteUserInfoIDs } = checkStateRequest.stateChanges; if (!userInfos && !deleteUserInfoIDs) { return state; } const newUserInfos = { ...state.userInfos }; if (userInfos) { for (const userInfo of userInfos) { newUserInfos[userInfo.id] = userInfo; } } if (deleteUserInfoIDs) { for (const deleteUserInfoID of deleteUserInfoIDs) { delete newUserInfos[deleteUserInfoID]; } } const newInconsistencies = findInconsistencies( action, state.userInfos, newUserInfos, ); return { userInfos: newUserInfos, inconsistencyReports: [ ...state.inconsistencyReports, ...newInconsistencies, ], }; } return state; } export { reduceCurrentUserInfo, reduceUserInfos }; diff --git a/lib/selectors/account-selectors.js b/lib/selectors/account-selectors.js index ba847efab..2ec0cbe3f 100644 --- a/lib/selectors/account-selectors.js +++ b/lib/selectors/account-selectors.js @@ -1,50 +1,50 @@ // @flow -import type { AppState } from '../types/redux-types'; -import type { CalendarQuery } from '../types/entry-types'; +import { createSelector } from 'reselect'; + import type { LogInExtraInfo } from '../types/account-types'; import { isDeviceType } from '../types/device-types'; -import type { CurrentUserInfo } from '../types/user-types'; +import type { CalendarQuery } from '../types/entry-types'; +import type { AppState } from '../types/redux-types'; import type { PreRequestUserState } from '../types/session-types'; - -import { createSelector } from 'reselect'; +import type { CurrentUserInfo } from '../types/user-types'; +import { getConfig } from '../utils/config'; import { currentCalendarQuery } from './nav-selectors'; -import { getConfig } from '../utils/config'; const logInExtraInfoSelector: ( state: AppState, ) => (calendarActive: boolean) => LogInExtraInfo = createSelector( (state: AppState) => state.deviceToken, currentCalendarQuery, ( deviceToken: ?string, calendarQuery: (calendarActive: boolean) => CalendarQuery, ) => { let deviceTokenUpdateRequest = null; const platform = getConfig().platformDetails.platform; if (deviceToken && isDeviceType(platform)) { deviceTokenUpdateRequest = { deviceToken }; } // Return a function since we depend on the time of evaluation return (calendarActive: boolean): LogInExtraInfo => ({ calendarQuery: calendarQuery(calendarActive), deviceTokenUpdateRequest, }); }, ); const preRequestUserStateSelector: ( state: AppState, ) => PreRequestUserState = createSelector( (state: AppState) => state.currentUserInfo, (state: AppState) => state.cookie, (state: AppState) => state.sessionID, (currentUserInfo: ?CurrentUserInfo, cookie: ?string, sessionID: ?string) => ({ currentUserInfo, cookie, sessionID, }), ); export { logInExtraInfoSelector, preRequestUserStateSelector }; diff --git a/lib/selectors/calendar-filter-selectors.js b/lib/selectors/calendar-filter-selectors.js index 2d2b8cafd..a8da92622 100644 --- a/lib/selectors/calendar-filter-selectors.js +++ b/lib/selectors/calendar-filter-selectors.js @@ -1,104 +1,104 @@ // @flow -import type { BaseAppState } from '../types/redux-types'; +import { createSelector } from 'reselect'; + import { type CalendarFilter, calendarThreadFilterTypes, type CalendarThreadFilterType, } from '../types/filter-types'; - -import { createSelector } from 'reselect'; +import type { BaseAppState } from '../types/redux-types'; function filteredThreadIDs( calendarFilters: $ReadOnlyArray, ): ?Set { let threadIDs = []; let threadListFilterExists = false; for (let filter of calendarFilters) { if (filter.type === calendarThreadFilterTypes.THREAD_LIST) { threadListFilterExists = true; threadIDs = [...threadIDs, ...filter.threadIDs]; } } if (!threadListFilterExists) { return null; } return new Set(threadIDs); } const filteredThreadIDsSelector: ( state: BaseAppState<*>, ) => ?Set = createSelector( (state: BaseAppState<*>) => state.calendarFilters, filteredThreadIDs, ); function filterFilters( calendarFilters: $ReadOnlyArray, filterTypeToExclude: CalendarThreadFilterType, ): $ReadOnlyArray { const filteredFilters = []; for (let filter of calendarFilters) { if (filter.type !== filterTypeToExclude) { filteredFilters.push(filter); } } return filteredFilters; } function nonThreadCalendarFilters( calendarFilters: $ReadOnlyArray, ): $ReadOnlyArray { return filterFilters(calendarFilters, calendarThreadFilterTypes.THREAD_LIST); } const nonThreadCalendarFiltersSelector: ( state: BaseAppState<*>, ) => $ReadOnlyArray = createSelector( (state: BaseAppState<*>) => state.calendarFilters, nonThreadCalendarFilters, ); function nonExcludeDeletedCalendarFilters( calendarFilters: $ReadOnlyArray, ): $ReadOnlyArray { return filterFilters(calendarFilters, calendarThreadFilterTypes.NOT_DELETED); } const nonExcludeDeletedCalendarFiltersSelector: ( state: BaseAppState<*>, ) => $ReadOnlyArray = createSelector( (state: BaseAppState<*>) => state.calendarFilters, nonExcludeDeletedCalendarFilters, ); function filterExists( calendarFilters: $ReadOnlyArray, filterType: CalendarThreadFilterType, ): boolean { for (let filter of calendarFilters) { if (filter.type === filterType) { return true; } } return false; } const includeDeletedSelector: ( state: BaseAppState<*>, ) => boolean = createSelector( (state: BaseAppState<*>) => state.calendarFilters, (calendarFilters: $ReadOnlyArray) => !filterExists(calendarFilters, calendarThreadFilterTypes.NOT_DELETED), ); export { filteredThreadIDs, filteredThreadIDsSelector, nonThreadCalendarFilters, nonThreadCalendarFiltersSelector, nonExcludeDeletedCalendarFilters, nonExcludeDeletedCalendarFiltersSelector, filterExists, includeDeletedSelector, }; diff --git a/lib/selectors/calendar-selectors.js b/lib/selectors/calendar-selectors.js index 0e3d613be..991aae6ab 100644 --- a/lib/selectors/calendar-selectors.js +++ b/lib/selectors/calendar-selectors.js @@ -1,86 +1,86 @@ // @flow -import type { BaseAppState } from '../types/redux-types'; -import type { ThreadInfo } from '../types/thread-types'; -import type { RawEntryInfo, CalendarQuery } from '../types/entry-types'; -import { type FilterThreadInfo } from '../types/filter-types'; - import { createSelector } from 'reselect'; -import { threadInfoSelector } from './thread-selectors'; -import { currentCalendarQuery } from './nav-selectors'; import { rawEntryInfoWithinActiveRange } from '../shared/entry-utils'; -import { threadInFilterList } from '../shared/thread-utils'; import SearchIndex from '../shared/search-index'; +import { threadInFilterList } from '../shared/thread-utils'; +import type { RawEntryInfo, CalendarQuery } from '../types/entry-types'; +import { type FilterThreadInfo } from '../types/filter-types'; +import type { BaseAppState } from '../types/redux-types'; +import type { ThreadInfo } from '../types/thread-types'; import { values } from '../utils/objects'; +import { currentCalendarQuery } from './nav-selectors'; +import { threadInfoSelector } from './thread-selectors'; + const filterThreadInfos: ( state: BaseAppState<*>, ) => ( calendarActive: boolean, ) => $ReadOnlyArray = createSelector( threadInfoSelector, currentCalendarQuery, (state: BaseAppState<*>) => state.entryStore.entryInfos, ( threadInfos: { [id: string]: ThreadInfo }, calendarQueryFunc: (calendarActive: boolean) => CalendarQuery, rawEntryInfos: { [id: string]: RawEntryInfo }, ) => (calendarActive: boolean) => { const calendarQuery = calendarQueryFunc(calendarActive); const result: { [threadID: string]: FilterThreadInfo } = {}; for (let entryID in rawEntryInfos) { const rawEntryInfo = rawEntryInfos[entryID]; if (!rawEntryInfoWithinActiveRange(rawEntryInfo, calendarQuery)) { continue; } const threadID = rawEntryInfo.threadID; const threadInfo = threadInfos[rawEntryInfo.threadID]; if (!threadInFilterList(threadInfo)) { continue; } if (result[threadID]) { result[threadID].numVisibleEntries++; } else { result[threadID] = { threadInfo, numVisibleEntries: 1, }; } } for (let threadID in threadInfos) { const threadInfo = threadInfos[threadID]; if (!result[threadID] && threadInFilterList(threadInfo)) { result[threadID] = { threadInfo, numVisibleEntries: 0, }; } } return values(result).sort( (first: FilterThreadInfo, second: FilterThreadInfo) => second.numVisibleEntries - first.numVisibleEntries, ); }, ); const filterThreadSearchIndex: ( state: BaseAppState<*>, ) => (calendarActive: boolean) => SearchIndex = createSelector( filterThreadInfos, ( threadInfoFunc: ( calendarActive: boolean, ) => $ReadOnlyArray, ) => (calendarActive: boolean) => { const threadInfos = threadInfoFunc(calendarActive); const searchIndex = new SearchIndex(); for (const filterThreadInfo of threadInfos) { const { threadInfo } = filterThreadInfo; searchIndex.addEntry(threadInfo.id, threadInfo.uiName); } return searchIndex; }, ); export { filterThreadInfos, filterThreadSearchIndex }; diff --git a/lib/selectors/chat-selectors.js b/lib/selectors/chat-selectors.js index 4645b9182..455965102 100644 --- a/lib/selectors/chat-selectors.js +++ b/lib/selectors/chat-selectors.js @@ -1,357 +1,357 @@ // @flow -import type { BaseAppState } from '../types/redux-types'; +import invariant from 'invariant'; +import _filter from 'lodash/fp/filter'; +import _flow from 'lodash/fp/flow'; +import _map from 'lodash/fp/map'; +import _orderBy from 'lodash/fp/orderBy'; +import _memoize from 'lodash/memoize'; +import PropTypes from 'prop-types'; +import { createSelector } from 'reselect'; +import { createObjectSelector } from 'reselect-map'; + import { - type ThreadInfo, - threadInfoPropType, - type SidebarInfo, - maxReadSidebars, - maxUnreadSidebars, -} from '../types/thread-types'; + messageKey, + robotextForMessageInfo, + createMessageInfo, + getMostRecentNonLocalMessageID, +} from '../shared/message-utils'; +import { threadIsTopLevel } from '../shared/thread-utils'; import { type MessageInfo, type MessageStore, type ComposableMessageInfo, type RobotextMessageInfo, type LocalMessageInfo, messageInfoPropType, localMessageInfoPropType, messageTypes, isComposableMessageType, } from '../types/message-types'; - -import { createSelector } from 'reselect'; -import { createObjectSelector } from 'reselect-map'; -import PropTypes from 'prop-types'; -import invariant from 'invariant'; -import _flow from 'lodash/fp/flow'; -import _filter from 'lodash/fp/filter'; -import _map from 'lodash/fp/map'; -import _orderBy from 'lodash/fp/orderBy'; -import _memoize from 'lodash/memoize'; - +import type { BaseAppState } from '../types/redux-types'; import { - messageKey, - robotextForMessageInfo, - createMessageInfo, - getMostRecentNonLocalMessageID, -} from '../shared/message-utils'; + type ThreadInfo, + threadInfoPropType, + type SidebarInfo, + maxReadSidebars, + maxUnreadSidebars, +} from '../types/thread-types'; + import { threadInfoSelector, sidebarInfoSelector } from './thread-selectors'; -import { threadIsTopLevel } from '../shared/thread-utils'; type SidebarItem = | {| ...SidebarInfo, +type: 'sidebar', |} | {| +type: 'seeMore', +unread: boolean, |}; export type ChatThreadItem = {| +type: 'chatThreadItem', +threadInfo: ThreadInfo, +mostRecentMessageInfo: ?MessageInfo, +mostRecentNonLocalMessage: ?string, +lastUpdatedTime: number, +lastUpdatedTimeIncludingSidebars: number, +sidebars: $ReadOnlyArray, |}; const chatThreadItemPropType = PropTypes.exact({ type: PropTypes.oneOf(['chatThreadItem']).isRequired, threadInfo: threadInfoPropType.isRequired, mostRecentMessageInfo: messageInfoPropType, mostRecentNonLocalMessage: PropTypes.string, lastUpdatedTime: PropTypes.number.isRequired, lastUpdatedTimeIncludingSidebars: PropTypes.number.isRequired, sidebars: PropTypes.arrayOf( PropTypes.oneOfType([ PropTypes.exact({ type: PropTypes.oneOf(['sidebar']).isRequired, threadInfo: threadInfoPropType.isRequired, lastUpdatedTime: PropTypes.number.isRequired, mostRecentNonLocalMessage: PropTypes.string, }), PropTypes.exact({ type: PropTypes.oneOf(['seeMore']).isRequired, unread: PropTypes.bool.isRequired, }), ]), ).isRequired, }); const messageInfoSelector: ( state: BaseAppState<*>, ) => { [id: string]: MessageInfo } = createObjectSelector( (state: BaseAppState<*>) => state.messageStore.messages, (state: BaseAppState<*>) => state.currentUserInfo && state.currentUserInfo.id, (state: BaseAppState<*>) => state.userStore.userInfos, threadInfoSelector, createMessageInfo, ); function getMostRecentMessageInfo( threadInfo: ThreadInfo, messageStore: MessageStore, messages: { [id: string]: MessageInfo }, ): ?MessageInfo { const thread = messageStore.threads[threadInfo.id]; if (!thread) { return null; } for (let messageID of thread.messageIDs) { return messages[messageID]; } return null; } function getLastUpdatedTime( threadInfo: ThreadInfo, mostRecentMessageInfo: ?MessageInfo, ): number { return mostRecentMessageInfo ? mostRecentMessageInfo.time : threadInfo.creationTime; } function createChatThreadItem( threadInfo: ThreadInfo, messageStore: MessageStore, messages: { [id: string]: MessageInfo }, sidebarInfos: ?$ReadOnlyArray, ): ChatThreadItem { const mostRecentMessageInfo = getMostRecentMessageInfo( threadInfo, messageStore, messages, ); const mostRecentNonLocalMessage = getMostRecentNonLocalMessageID( threadInfo, messageStore, ); const lastUpdatedTime = getLastUpdatedTime(threadInfo, mostRecentMessageInfo); const sidebars = sidebarInfos ?? []; const allSidebarItems = sidebars.map((sidebarInfo) => ({ type: 'sidebar', ...sidebarInfo, })); const lastUpdatedTimeIncludingSidebars = allSidebarItems.length > 0 ? Math.max(lastUpdatedTime, allSidebarItems[0].lastUpdatedTime) : lastUpdatedTime; const numUnreadSidebars = allSidebarItems.filter( (sidebar) => sidebar.threadInfo.currentUser.unread, ).length; let numReadSidebarsToShow = maxReadSidebars - numUnreadSidebars; const sidebarItems = []; for (const sidebar of allSidebarItems) { if (sidebarItems.length >= maxUnreadSidebars) { break; } else if (sidebar.threadInfo.currentUser.unread) { sidebarItems.push(sidebar); } else if (numReadSidebarsToShow > 0) { sidebarItems.push(sidebar); numReadSidebarsToShow--; } } if (sidebarItems.length < allSidebarItems.length) { sidebarItems.push({ type: 'seeMore', unread: numUnreadSidebars > maxUnreadSidebars, }); } return { type: 'chatThreadItem', threadInfo, mostRecentMessageInfo, mostRecentNonLocalMessage, lastUpdatedTime, lastUpdatedTimeIncludingSidebars, sidebars: sidebarItems, }; } const chatListData: ( state: BaseAppState<*>, ) => ChatThreadItem[] = createSelector( threadInfoSelector, (state: BaseAppState<*>) => state.messageStore, messageInfoSelector, sidebarInfoSelector, ( threadInfos: { [id: string]: ThreadInfo }, messageStore: MessageStore, messageInfos: { [id: string]: MessageInfo }, sidebarInfos: { [id: string]: $ReadOnlyArray }, ): ChatThreadItem[] => _flow( _filter(threadIsTopLevel), _map((threadInfo: ThreadInfo): ChatThreadItem => createChatThreadItem( threadInfo, messageStore, messageInfos, sidebarInfos[threadInfo.id], ), ), _orderBy('lastUpdatedTimeIncludingSidebars')('desc'), )(threadInfos), ); export type RobotextChatMessageInfoItem = {| itemType: 'message', messageInfo: RobotextMessageInfo, startsConversation: boolean, startsCluster: boolean, endsCluster: boolean, robotext: string, |}; export type ChatMessageInfoItem = | RobotextChatMessageInfoItem | {| itemType: 'message', messageInfo: ComposableMessageInfo, localMessageInfo: ?LocalMessageInfo, startsConversation: boolean, startsCluster: boolean, endsCluster: boolean, |}; export type ChatMessageItem = {| itemType: 'loader' |} | ChatMessageInfoItem; const chatMessageItemPropType = PropTypes.oneOfType([ PropTypes.shape({ itemType: PropTypes.oneOf(['loader']).isRequired, }), PropTypes.shape({ itemType: PropTypes.oneOf(['message']).isRequired, messageInfo: messageInfoPropType.isRequired, localMessageInfo: localMessageInfoPropType, startsConversation: PropTypes.bool.isRequired, startsCluster: PropTypes.bool.isRequired, endsCluster: PropTypes.bool.isRequired, robotext: PropTypes.string, }), ]); const msInFiveMinutes = 5 * 60 * 1000; function createChatMessageItems( threadID: string, messageStore: MessageStore, messageInfos: { [id: string]: MessageInfo }, threadInfos: { [id: string]: ThreadInfo }, ): ChatMessageItem[] { const thread = messageStore.threads[threadID]; if (!thread) { return []; } const threadMessageInfos = thread.messageIDs .map((messageID: string) => messageInfos[messageID]) .filter(Boolean); const chatMessageItems = []; let lastMessageInfo = null; for (let i = threadMessageInfos.length - 1; i >= 0; i--) { const messageInfo = threadMessageInfos[i]; let startsConversation = true; let startsCluster = true; if ( lastMessageInfo && lastMessageInfo.time + msInFiveMinutes > messageInfo.time ) { startsConversation = false; if ( isComposableMessageType(lastMessageInfo.type) && isComposableMessageType(messageInfo.type) && lastMessageInfo.creator.id === messageInfo.creator.id ) { startsCluster = false; } } if (startsCluster && chatMessageItems.length > 0) { const lastMessageItem = chatMessageItems[chatMessageItems.length - 1]; invariant(lastMessageItem.itemType === 'message', 'should be message'); lastMessageItem.endsCluster = true; } if (isComposableMessageType(messageInfo.type)) { // We use these invariants instead of just checking the messageInfo.type // directly in the conditional above so that isComposableMessageType can // be the source of truth invariant( messageInfo.type === messageTypes.TEXT || messageInfo.type === messageTypes.IMAGES || messageInfo.type === messageTypes.MULTIMEDIA, "Flow doesn't understand isComposableMessageType above", ); const localMessageInfo = messageStore.local[messageKey(messageInfo)]; chatMessageItems.push({ itemType: 'message', messageInfo, localMessageInfo, startsConversation, startsCluster, endsCluster: false, }); } else { invariant( messageInfo.type !== messageTypes.TEXT && messageInfo.type !== messageTypes.IMAGES && messageInfo.type !== messageTypes.MULTIMEDIA, "Flow doesn't understand isComposableMessageType above", ); const robotext = robotextForMessageInfo( messageInfo, threadInfos[threadID], ); chatMessageItems.push({ itemType: 'message', messageInfo, startsConversation, startsCluster, endsCluster: false, robotext, }); } lastMessageInfo = messageInfo; } if (chatMessageItems.length > 0) { const lastMessageItem = chatMessageItems[chatMessageItems.length - 1]; invariant(lastMessageItem.itemType === 'message', 'should be message'); lastMessageItem.endsCluster = true; } chatMessageItems.reverse(); if (thread.startReached) { return chatMessageItems; } return [...chatMessageItems, ({ itemType: 'loader' }: ChatMessageItem)]; } const baseMessageListData = (threadID: string) => createSelector( (state: BaseAppState<*>) => state.messageStore, messageInfoSelector, threadInfoSelector, ( messageStore: MessageStore, messageInfos: { [id: string]: MessageInfo }, threadInfos: { [id: string]: ThreadInfo }, ): ChatMessageItem[] => createChatMessageItems(threadID, messageStore, messageInfos, threadInfos), ); const messageListData: ( threadID: string, ) => (state: BaseAppState<*>) => ChatMessageItem[] = _memoize( baseMessageListData, ); export { messageInfoSelector, createChatThreadItem, chatThreadItemPropType, chatListData, chatMessageItemPropType, createChatMessageItems, messageListData, }; diff --git a/lib/selectors/loading-selectors.js b/lib/selectors/loading-selectors.js index 9b72bb3a3..ebe0c97f2 100644 --- a/lib/selectors/loading-selectors.js +++ b/lib/selectors/loading-selectors.js @@ -1,98 +1,97 @@ // @flow -import type { BaseAppState } from '../types/redux-types'; -import type { LoadingStatus } from '../types/loading-types'; -import type { ActionTypes } from '../utils/action-utils'; - -import { createSelector } from 'reselect'; -import _isEmpty from 'lodash/fp/isEmpty'; +import invariant from 'invariant'; import _includes from 'lodash/fp/includes'; +import _isEmpty from 'lodash/fp/isEmpty'; import _memoize from 'lodash/memoize'; -import invariant from 'invariant'; +import { createSelector } from 'reselect'; import { registerFetchKey } from '../reducers/loading-reducer'; +import type { LoadingStatus } from '../types/loading-types'; +import type { BaseAppState } from '../types/redux-types'; +import type { ActionTypes } from '../utils/action-utils'; import { values } from '../utils/objects'; function loadingStatusFromInfo(loadingStatusInfo: { [idx: number]: LoadingStatus, }): LoadingStatus { if (_isEmpty(loadingStatusInfo)) { return 'inactive'; } else if (_includes('error')(loadingStatusInfo)) { return 'error'; } else { return 'loading'; } } // This is the key used to store the Promise state in Redux function getTrackingKey( actionTypes: ActionTypes<*, *, *>, overrideKey?: string, ) { if (overrideKey) { return overrideKey; } const startMatch = actionTypes.started.match(/(.*)_STARTED/); invariant( startMatch && startMatch[1], 'actionTypes.started should always end with _STARTED', ); return startMatch[1]; } const baseCreateLoadingStatusSelector = ( actionTypes: ActionTypes<*, *, *>, overrideKey?: string, ): ((state: BaseAppState<*>) => LoadingStatus) => { // This makes sure that reduceLoadingStatuses tracks this action registerFetchKey(actionTypes); const trackingKey = getTrackingKey(actionTypes, overrideKey); return createSelector( (state: BaseAppState<*>) => state.loadingStatuses[trackingKey], (loadingStatusInfo: { [idx: number]: LoadingStatus }) => loadingStatusFromInfo(loadingStatusInfo), ); }; const createLoadingStatusSelector: ( actionTypes: ActionTypes<*, *, *>, overrideKey?: string, ) => (state: BaseAppState<*>) => LoadingStatus = _memoize( baseCreateLoadingStatusSelector, getTrackingKey, ); function combineLoadingStatuses( ...loadingStatuses: $ReadOnlyArray ): LoadingStatus { let errorExists = false; for (let loadingStatus of loadingStatuses) { if (loadingStatus === 'loading') { return 'loading'; } if (loadingStatus === 'error') { errorExists = true; } } return errorExists ? 'error' : 'inactive'; } const globalLoadingStatusSelector: ( state: BaseAppState<*>, ) => LoadingStatus = createSelector( (state: BaseAppState<*>) => state.loadingStatuses, (loadingStatusInfos: { [key: string]: { [idx: number]: LoadingStatus }, }): LoadingStatus => { const loadingStatusInfoValues = values(loadingStatusInfos); const loadingStatuses = loadingStatusInfoValues.map(loadingStatusFromInfo); return combineLoadingStatuses(...loadingStatuses); }, ); export { createLoadingStatusSelector, globalLoadingStatusSelector, combineLoadingStatuses, }; diff --git a/lib/selectors/local-id-selectors.js b/lib/selectors/local-id-selectors.js index 5ffd68735..a4e00aafe 100644 --- a/lib/selectors/local-id-selectors.js +++ b/lib/selectors/local-id-selectors.js @@ -1,51 +1,51 @@ // @flow -import type { BaseAppState } from '../types/redux-types'; - import invariant from 'invariant'; +import type { BaseAppState } from '../types/redux-types'; + const localIDExtractionRegex = /^local([0-9]+)$/; function numberFromLocalID(localID: string) { const matches = localIDExtractionRegex.exec(localID); invariant(matches && matches[1], `${localID} doesn't look like a localID`); return parseInt(matches[1], 10); } function highestLocalIDSelector(state: ?BaseAppState<*>): number { let highestLocalIDFound = -1; if (state && state.messageStore) { for (let messageKey in state.messageStore.messages) { const messageInfo = state.messageStore.messages[messageKey]; if (!messageInfo.localID) { continue; } const { localID } = messageInfo; if (!localID) { continue; } const thisLocalID = numberFromLocalID(localID); if (thisLocalID > highestLocalIDFound) { highestLocalIDFound = thisLocalID; } } } if (state && state.entryStore) { for (let entryKey in state.entryStore.entryInfos) { const { localID } = state.entryStore.entryInfos[entryKey]; if (!localID) { continue; } const thisLocalID = numberFromLocalID(localID); if (thisLocalID > highestLocalIDFound) { highestLocalIDFound = thisLocalID; } } } return highestLocalIDFound; } export { numberFromLocalID, highestLocalIDSelector }; diff --git a/lib/selectors/nav-selectors.js b/lib/selectors/nav-selectors.js index 248264328..d6aeb34c6 100644 --- a/lib/selectors/nav-selectors.js +++ b/lib/selectors/nav-selectors.js @@ -1,88 +1,87 @@ // @flow -import type { BaseAppState } from '../types/redux-types'; -import type { BaseNavInfo } from '../types/nav-types'; -import type { RawThreadInfo } from '../types/thread-types'; -import { type CalendarQuery, defaultCalendarQuery } from '../types/entry-types'; -import type { UserInfos } from '../types/user-types'; -import type { CalendarFilter } from '../types/filter-types'; -import type { Platform } from '../types/device-types'; - import { createSelector } from 'reselect'; -import { getConfig } from '../utils/config'; import SearchIndex from '../shared/search-index'; import { threadSearchText } from '../shared/thread-utils'; +import type { Platform } from '../types/device-types'; +import { type CalendarQuery, defaultCalendarQuery } from '../types/entry-types'; +import type { CalendarFilter } from '../types/filter-types'; +import type { BaseNavInfo } from '../types/nav-types'; +import type { BaseAppState } from '../types/redux-types'; +import type { RawThreadInfo } from '../types/thread-types'; +import type { UserInfos } from '../types/user-types'; +import { getConfig } from '../utils/config'; function timeUntilCalendarRangeExpiration( lastUserInteractionCalendar: number, ): ?number { const inactivityLimit = getConfig().calendarRangeInactivityLimit; if (inactivityLimit === null || inactivityLimit === undefined) { return null; } return lastUserInteractionCalendar + inactivityLimit - Date.now(); } function calendarRangeExpired(lastUserInteractionCalendar: number): boolean { const timeUntil = timeUntilCalendarRangeExpiration( lastUserInteractionCalendar, ); if (timeUntil === null || timeUntil === undefined) { return false; } return timeUntil <= 0; } const currentCalendarQuery: ( state: BaseAppState<*>, ) => (calendarActive: boolean) => CalendarQuery = createSelector( (state: BaseAppState<*>) => state.entryStore.lastUserInteractionCalendar, (state: BaseAppState<*>) => state.navInfo, (state: BaseAppState<*>) => state.calendarFilters, ( lastUserInteractionCalendar: number, navInfo: BaseNavInfo, calendarFilters: $ReadOnlyArray, ) => { // Return a function since we depend on the time of evaluation return (calendarActive: boolean, platform: ?Platform): CalendarQuery => { if (calendarActive) { return { startDate: navInfo.startDate, endDate: navInfo.endDate, filters: calendarFilters, }; } if (calendarRangeExpired(lastUserInteractionCalendar)) { return defaultCalendarQuery(platform); } return { startDate: navInfo.startDate, endDate: navInfo.endDate, filters: calendarFilters, }; }; }, ); const threadSearchIndex: ( state: BaseAppState<*>, ) => SearchIndex = createSelector( (state: BaseAppState<*>) => state.threadStore.threadInfos, (state: BaseAppState<*>) => state.userStore.userInfos, (threadInfos: { [id: string]: RawThreadInfo }, userInfos: UserInfos) => { const searchIndex = new SearchIndex(); for (const threadID in threadInfos) { const thread = threadInfos[threadID]; searchIndex.addEntry(threadID, threadSearchText(thread, userInfos)); } return searchIndex; }, ); export { timeUntilCalendarRangeExpiration, currentCalendarQuery, threadSearchIndex, }; diff --git a/lib/selectors/relationship-selectors.js b/lib/selectors/relationship-selectors.js index 7d0668e90..e591aec8d 100644 --- a/lib/selectors/relationship-selectors.js +++ b/lib/selectors/relationship-selectors.js @@ -1,50 +1,50 @@ // @flow +import _orderBy from 'lodash/fp/orderBy'; +import { createSelector } from 'reselect'; + import type { BaseAppState } from '../types/redux-types'; -import type { UserInfos } from '../types/user-types'; import { userRelationshipStatus, type UserRelationships, } from '../types/relationship-types'; - -import { createSelector } from 'reselect'; -import _orderBy from 'lodash/fp/orderBy'; +import type { UserInfos } from '../types/user-types'; const userRelationshipsSelector: ( state: BaseAppState<*>, ) => UserRelationships = createSelector( (state: BaseAppState<*>) => state.userStore.userInfos, (userInfos: UserInfos) => { const unorderedFriendRequests = []; const unorderedFriends = []; const blocked = []; for (const userID in userInfos) { const userInfo = userInfos[userID]; const { id, username, relationshipStatus } = userInfo; if (!username) { continue; } if ( relationshipStatus === userRelationshipStatus.REQUEST_RECEIVED || relationshipStatus === userRelationshipStatus.REQUEST_SENT ) { unorderedFriendRequests.push({ id, username, relationshipStatus }); } else if (relationshipStatus === userRelationshipStatus.FRIEND) { unorderedFriends.push({ id, username, relationshipStatus }); } else if ( relationshipStatus === userRelationshipStatus.BLOCKED_BY_VIEWER || relationshipStatus === userRelationshipStatus.BOTH_BLOCKED ) { blocked.push({ id, username, relationshipStatus }); } } const friendRequests = _orderBy('relationshipStatus')('desc')( unorderedFriendRequests, ); const friends = friendRequests.concat(unorderedFriends); return { friends, blocked }; }, ); export { userRelationshipsSelector }; diff --git a/lib/selectors/server-calls.js b/lib/selectors/server-calls.js index 1c0d43c1f..3625ff909 100644 --- a/lib/selectors/server-calls.js +++ b/lib/selectors/server-calls.js @@ -1,52 +1,52 @@ // @flow +import PropTypes from 'prop-types'; +import { createSelector } from 'reselect'; + import type { AppState } from '../types/redux-types'; import { type ConnectionStatus, connectionStatusPropType, } from '../types/socket-types'; import { type CurrentUserInfo, currentUserPropType } from '../types/user-types'; -import { createSelector } from 'reselect'; -import PropTypes from 'prop-types'; - export type ServerCallState = {| cookie: ?string, urlPrefix: string, sessionID: ?string, currentUserInfo: ?CurrentUserInfo, connectionStatus: ConnectionStatus, |}; const serverCallStatePropType = PropTypes.shape({ cookie: PropTypes.string, urlPrefix: PropTypes.string.isRequired, sessionID: PropTypes.string, currentUserInfo: currentUserPropType, connectionStatus: connectionStatusPropType.isRequired, }); const serverCallStateSelector: ( state: AppState, ) => ServerCallState = createSelector( (state: AppState) => state.cookie, (state: AppState) => state.urlPrefix, (state: AppState) => state.sessionID, (state: AppState) => state.currentUserInfo, (state: AppState) => state.connection.status, ( cookie: ?string, urlPrefix: string, sessionID: ?string, currentUserInfo: ?CurrentUserInfo, connectionStatus: ConnectionStatus, ) => ({ cookie, urlPrefix, sessionID, currentUserInfo, connectionStatus, }), ); export { serverCallStatePropType, serverCallStateSelector }; diff --git a/lib/selectors/socket-selectors.js b/lib/selectors/socket-selectors.js index ebed98a6a..c05a67f02 100644 --- a/lib/selectors/socket-selectors.js +++ b/lib/selectors/socket-selectors.js @@ -1,180 +1,180 @@ // @flow -import type { AppState } from '../types/redux-types'; +import { createSelector } from 'reselect'; + import { - serverRequestTypes, - type ServerRequest, - type ClientClientResponse, -} from '../types/request-types'; + serverEntryInfo, + serverEntryInfosObject, + filterRawEntryInfosByCalendarQuery, +} from '../shared/entry-utils'; +import threadWatcher from '../shared/thread-watcher'; import type { RawEntryInfo, CalendarQuery } from '../types/entry-types'; -import type { CurrentUserInfo, UserInfos } from '../types/user-types'; -import type { RawThreadInfo } from '../types/thread-types'; -import type { SessionState } from '../types/session-types'; +import type { AppState } from '../types/redux-types'; import type { ClientThreadInconsistencyReportCreationRequest, ClientEntryInconsistencyReportCreationRequest, ClientReportCreationRequest, } from '../types/report-types'; - -import { createSelector } from 'reselect'; - -import { getConfig } from '../utils/config'; import { - serverEntryInfo, - serverEntryInfosObject, - filterRawEntryInfosByCalendarQuery, -} from '../shared/entry-utils'; + serverRequestTypes, + type ServerRequest, + type ClientClientResponse, +} from '../types/request-types'; +import type { SessionState } from '../types/session-types'; +import type { RawThreadInfo } from '../types/thread-types'; +import type { CurrentUserInfo, UserInfos } from '../types/user-types'; +import { getConfig } from '../utils/config'; import { values, hash } from '../utils/objects'; + import { currentCalendarQuery } from './nav-selectors'; -import threadWatcher from '../shared/thread-watcher'; const queuedReports: ( state: AppState, ) => $ReadOnlyArray = createSelector( (state: AppState) => state.threadStore.inconsistencyReports, (state: AppState) => state.entryStore.inconsistencyReports, (state: AppState) => state.queuedReports, ( threadInconsistencyReports: $ReadOnlyArray, entryInconsistencyReports: $ReadOnlyArray, mainQueuedReports: $ReadOnlyArray, ): $ReadOnlyArray => [ ...threadInconsistencyReports, ...entryInconsistencyReports, ...mainQueuedReports, ], ); const getClientResponsesSelector: ( state: AppState, ) => ( calendarActive: boolean, serverRequests: $ReadOnlyArray, ) => $ReadOnlyArray = createSelector( (state: AppState) => state.threadStore.threadInfos, (state: AppState) => state.entryStore.entryInfos, (state: AppState) => state.userStore.userInfos, (state: AppState) => state.currentUserInfo, currentCalendarQuery, ( threadInfos: { [id: string]: RawThreadInfo }, entryInfos: { [id: string]: RawEntryInfo }, userInfos: UserInfos, currentUserInfo: ?CurrentUserInfo, calendarQuery: (calendarActive: boolean) => CalendarQuery, ) => ( calendarActive: boolean, serverRequests: $ReadOnlyArray, ): $ReadOnlyArray => { const clientResponses = []; const serverRequestedPlatformDetails = serverRequests.some( (request) => request.type === serverRequestTypes.PLATFORM_DETAILS, ); for (let serverRequest of serverRequests) { if ( serverRequest.type === serverRequestTypes.PLATFORM && !serverRequestedPlatformDetails ) { clientResponses.push({ type: serverRequestTypes.PLATFORM, platform: getConfig().platformDetails.platform, }); } else if (serverRequest.type === serverRequestTypes.PLATFORM_DETAILS) { clientResponses.push({ type: serverRequestTypes.PLATFORM_DETAILS, platformDetails: getConfig().platformDetails, }); } else if (serverRequest.type === serverRequestTypes.CHECK_STATE) { const filteredEntryInfos = filterRawEntryInfosByCalendarQuery( serverEntryInfosObject(values(entryInfos)), calendarQuery(calendarActive), ); const hashResults = {}; for (let key in serverRequest.hashesToCheck) { const expectedHashValue = serverRequest.hashesToCheck[key]; let hashValue; if (key === 'threadInfos') { hashValue = hash(threadInfos); } else if (key === 'entryInfos') { hashValue = hash(filteredEntryInfos); } else if (key === 'userInfos') { hashValue = hash(userInfos); } else if (key === 'currentUserInfo') { hashValue = hash(currentUserInfo); } else if (key.startsWith('threadInfo|')) { const [, threadID] = key.split('|'); hashValue = hash(threadInfos[threadID]); } else if (key.startsWith('entryInfo|')) { const [, entryID] = key.split('|'); let rawEntryInfo = filteredEntryInfos[entryID]; if (rawEntryInfo) { rawEntryInfo = serverEntryInfo(rawEntryInfo); } hashValue = hash(rawEntryInfo); } else if (key.startsWith('userInfo|')) { const [, userID] = key.split('|'); hashValue = hash(userInfos[userID]); } else { continue; } hashResults[key] = expectedHashValue === hashValue; } const { failUnmentioned } = serverRequest; if (failUnmentioned && failUnmentioned.threadInfos) { for (let threadID in threadInfos) { const key = `threadInfo|${threadID}`; const hashResult = hashResults[key]; if (hashResult === null || hashResult === undefined) { hashResults[key] = false; } } } if (failUnmentioned && failUnmentioned.entryInfos) { for (let entryID in filteredEntryInfos) { const key = `entryInfo|${entryID}`; const hashResult = hashResults[key]; if (hashResult === null || hashResult === undefined) { hashResults[key] = false; } } } if (failUnmentioned && failUnmentioned.userInfos) { for (let userID in userInfos) { const key = `userInfo|${userID}`; const hashResult = hashResults[key]; if (hashResult === null || hashResult === undefined) { hashResults[key] = false; } } } clientResponses.push({ type: serverRequestTypes.CHECK_STATE, hashResults, }); } } return clientResponses; }, ); const sessionStateFuncSelector: ( state: AppState, ) => (calendarActive: boolean) => SessionState = createSelector( (state: AppState) => state.messageStore.currentAsOf, (state: AppState) => state.updatesCurrentAsOf, currentCalendarQuery, ( messagesCurrentAsOf: number, updatesCurrentAsOf: number, calendarQuery: (calendarActive: boolean) => CalendarQuery, ) => (calendarActive: boolean): SessionState => ({ calendarQuery: calendarQuery(calendarActive), messagesCurrentAsOf, updatesCurrentAsOf, watchedIDs: threadWatcher.getWatchedIDs(), }), ); export { queuedReports, getClientResponsesSelector, sessionStateFuncSelector }; diff --git a/lib/selectors/thread-selectors.js b/lib/selectors/thread-selectors.js index cd87772e2..4d22ee65b 100644 --- a/lib/selectors/thread-selectors.js +++ b/lib/selectors/thread-selectors.js @@ -1,376 +1,377 @@ // @flow -import type { BaseAppState } from '../types/redux-types'; -import { - type ThreadInfo, - type RawThreadInfo, - type RelativeMemberInfo, - threadPermissions, - threadTypes, - type SidebarInfo, -} from '../types/thread-types'; -import type { EntryInfo } from '../types/entry-types'; -import type { MessageStore, RawMessageInfo } from '../types/message-types'; - -import { createSelector } from 'reselect'; -import { createObjectSelector } from 'reselect-map'; -import _flow from 'lodash/fp/flow'; -import _some from 'lodash/fp/some'; -import _mapValues from 'lodash/fp/mapValues'; -import _map from 'lodash/fp/map'; +import invariant from 'invariant'; import _compact from 'lodash/fp/compact'; import _filter from 'lodash/fp/filter'; +import _flow from 'lodash/fp/flow'; +import _map from 'lodash/fp/map'; +import _mapValues from 'lodash/fp/mapValues'; +import _orderBy from 'lodash/fp/orderBy'; +import _some from 'lodash/fp/some'; import _sortBy from 'lodash/fp/sortBy'; import _memoize from 'lodash/memoize'; -import _orderBy from 'lodash/fp/orderBy'; -const _mapValuesWithKeys = _mapValues.convert({ cap: false }); -import invariant from 'invariant'; +import { createSelector } from 'reselect'; +import { createObjectSelector } from 'reselect-map'; -import { dateString, dateFromString } from '../utils/date-utils'; -import { values } from '../utils/objects'; import { createEntryInfo } from '../shared/entry-utils'; +import { getMostRecentNonLocalMessageID } from '../shared/message-utils'; import { threadInHomeChatList, threadInBackgroundChatList, threadInFilterList, threadInfoFromRawThreadInfo, threadHasPermission, threadInChatList, threadHasAdminRole, roleIsAdminRole, } from '../shared/thread-utils'; -import { relativeMemberInfoSelectorForMembersOfThread } from './user-selectors'; +import type { EntryInfo } from '../types/entry-types'; +import type { MessageStore, RawMessageInfo } from '../types/message-types'; +import type { BaseAppState } from '../types/redux-types'; +import { + type ThreadInfo, + type RawThreadInfo, + type RelativeMemberInfo, + threadPermissions, + threadTypes, + type SidebarInfo, +} from '../types/thread-types'; +import { dateString, dateFromString } from '../utils/date-utils'; +import { values } from '../utils/objects'; + import { filteredThreadIDsSelector, includeDeletedSelector, } from './calendar-filter-selectors'; -import { getMostRecentNonLocalMessageID } from '../shared/message-utils'; +import { relativeMemberInfoSelectorForMembersOfThread } from './user-selectors'; + +const _mapValuesWithKeys = _mapValues.convert({ cap: false }); type ThreadInfoSelectorType = ( state: BaseAppState<*>, ) => { [id: string]: ThreadInfo }; const threadInfoSelector: ThreadInfoSelectorType = createObjectSelector( (state: BaseAppState<*>) => state.threadStore.threadInfos, (state: BaseAppState<*>) => state.currentUserInfo && state.currentUserInfo.id, (state: BaseAppState<*>) => state.userStore.userInfos, threadInfoFromRawThreadInfo, ); type ThreadSelectorType = ( possiblyPendingThread: ThreadInfo, ) => (state: BaseAppState<*>) => ?ThreadInfo; const possiblyPendingThreadInfoSelector: ThreadSelectorType = ( possiblyPendingThread: ThreadInfo, ) => createSelector( threadInfoSelector, (state: BaseAppState<*>) => state.currentUserInfo?.id, (threadInfos: { [id: string]: ThreadInfo }, currentUserID: ?string) => { const threadInfoFromState = threadInfos[possiblyPendingThread.id]; if (threadInfoFromState) { return threadInfoFromState; } if (possiblyPendingThread.type !== threadTypes.PERSONAL) { return undefined; } if (!currentUserID) { return possiblyPendingThread; } const otherMemberID = possiblyPendingThread.id.split('/')[1]; invariant( otherMemberID, 'Pending thread should contain other member id in its id', ); for (const threadID in threadInfos) { const threadInfo = threadInfos[threadID]; if (threadInfo.type !== threadTypes.PERSONAL) { continue; } invariant( threadInfo.members.length === 2, 'Personal thread should have exactly two members', ); const members = new Set(threadInfo.members.map((member) => member.id)); if (members.has(currentUserID) && members.has(otherMemberID)) { return threadInfo; } } return possiblyPendingThread; }, ); const canBeOnScreenThreadInfos: ( state: BaseAppState<*>, ) => ThreadInfo[] = createSelector( threadInfoSelector, (threadInfos: { [id: string]: ThreadInfo }) => { const result = []; for (let threadID in threadInfos) { const threadInfo = threadInfos[threadID]; if (!threadInFilterList(threadInfo)) { continue; } result.push(threadInfo); } return result; }, ); const onScreenThreadInfos: ( state: BaseAppState<*>, ) => ThreadInfo[] = createSelector( filteredThreadIDsSelector, canBeOnScreenThreadInfos, (inputThreadIDs: ?Set, threadInfos: ThreadInfo[]) => { const threadIDs = inputThreadIDs; if (!threadIDs) { return threadInfos; } return threadInfos.filter((threadInfo) => threadIDs.has(threadInfo.id)); }, ); const onScreenEntryEditableThreadInfos: ( state: BaseAppState<*>, ) => ThreadInfo[] = createSelector( onScreenThreadInfos, (threadInfos: ThreadInfo[]) => threadInfos.filter((threadInfo) => threadHasPermission(threadInfo, threadPermissions.EDIT_ENTRIES), ), ); const entryInfoSelector: ( state: BaseAppState<*>, ) => { [id: string]: EntryInfo } = createObjectSelector( (state: BaseAppState<*>) => state.entryStore.entryInfos, (state: BaseAppState<*>) => state.currentUserInfo && state.currentUserInfo.id, (state: BaseAppState<*>) => state.userStore.userInfos, createEntryInfo, ); // "current" means within startDate/endDate range, not deleted, and in // onScreenThreadInfos const currentDaysToEntries: ( state: BaseAppState<*>, ) => { [dayString: string]: EntryInfo[] } = createSelector( entryInfoSelector, (state: BaseAppState<*>) => state.entryStore.daysToEntries, (state: BaseAppState<*>) => state.navInfo.startDate, (state: BaseAppState<*>) => state.navInfo.endDate, onScreenThreadInfos, includeDeletedSelector, ( entryInfos: { [id: string]: EntryInfo }, daysToEntries: { [day: string]: string[] }, startDateString: string, endDateString: string, onScreen: ThreadInfo[], includeDeleted: boolean, ) => { const allDaysWithinRange = {}, startDate = dateFromString(startDateString), endDate = dateFromString(endDateString); for ( const curDate = startDate; curDate <= endDate; curDate.setDate(curDate.getDate() + 1) ) { allDaysWithinRange[dateString(curDate)] = []; } return _mapValuesWithKeys((_: string[], dayString: string) => _flow( _map((entryID: string) => entryInfos[entryID]), _compact, _filter( (entryInfo: EntryInfo) => (includeDeleted || !entryInfo.deleted) && _some(['id', entryInfo.threadID])(onScreen), ), _sortBy('creationTime'), )(daysToEntries[dayString] ? daysToEntries[dayString] : []), )(allDaysWithinRange); }, ); const childThreadInfos: ( state: BaseAppState<*>, ) => { [id: string]: $ReadOnlyArray } = createSelector( threadInfoSelector, (threadInfos: { [id: string]: ThreadInfo }) => { const result = {}; for (let id in threadInfos) { const threadInfo = threadInfos[id]; const parentThreadID = threadInfo.parentThreadID; if (parentThreadID === null || parentThreadID === undefined) { continue; } if (result[parentThreadID] === undefined) { result[parentThreadID] = []; } result[parentThreadID].push(threadInfo); } return result; }, ); function getMostRecentRawMessageInfo( threadInfo: ThreadInfo, messageStore: MessageStore, ): ?RawMessageInfo { const thread = messageStore.threads[threadInfo.id]; if (!thread) { return null; } for (let messageID of thread.messageIDs) { return messageStore.messages[messageID]; } return null; } const sidebarInfoSelector: ( state: BaseAppState<*>, ) => { [id: string]: $ReadOnlyArray } = createObjectSelector( childThreadInfos, (state: BaseAppState<*>) => state.messageStore, (childThreads: $ReadOnlyArray, messageStore: MessageStore) => { const sidebarInfos = []; for (const childThreadInfo of childThreads) { if ( !threadInChatList(childThreadInfo) || childThreadInfo.type !== threadTypes.SIDEBAR ) { continue; } const mostRecentRawMessageInfo = getMostRecentRawMessageInfo( childThreadInfo, messageStore, ); const lastUpdatedTime = mostRecentRawMessageInfo?.time ?? childThreadInfo.creationTime; const mostRecentNonLocalMessage = getMostRecentNonLocalMessageID( childThreadInfo, messageStore, ); sidebarInfos.push({ threadInfo: childThreadInfo, lastUpdatedTime, mostRecentNonLocalMessage, }); } return _orderBy('lastUpdatedTime')('desc')(sidebarInfos); }, ); const unreadCount: (state: BaseAppState<*>) => number = createSelector( (state: BaseAppState<*>) => state.threadStore.threadInfos, (threadInfos: { [id: string]: RawThreadInfo }): number => values(threadInfos).filter( (threadInfo) => threadInHomeChatList(threadInfo) && threadInfo.currentUser.unread, ).length, ); const unreadBackgroundCount: ( state: BaseAppState<*>, ) => number = createSelector( (state: BaseAppState<*>) => state.threadStore.threadInfos, (threadInfos: { [id: string]: RawThreadInfo }): number => values(threadInfos).filter( (threadInfo) => threadInBackgroundChatList(threadInfo) && threadInfo.currentUser.unread, ).length, ); const baseOtherUsersButNoOtherAdmins = (threadID: string) => createSelector( (state: BaseAppState<*>) => state.threadStore.threadInfos[threadID], relativeMemberInfoSelectorForMembersOfThread(threadID), ( threadInfo: ?RawThreadInfo, members: $ReadOnlyArray, ): boolean => { if (!threadInfo) { return false; } if (!threadHasAdminRole(threadInfo)) { return false; } let otherUsersExist = false; let otherAdminsExist = false; for (let member of members) { const role = member.role; if (role === undefined || role === null || member.isViewer) { continue; } otherUsersExist = true; if (roleIsAdminRole(threadInfo?.roles[role])) { otherAdminsExist = true; break; } } return otherUsersExist && !otherAdminsExist; }, ); const otherUsersButNoOtherAdmins: ( threadID: string, ) => (state: BaseAppState<*>) => boolean = _memoize( baseOtherUsersButNoOtherAdmins, ); function mostRecentReadThread( messageStore: MessageStore, threadInfos: { [id: string]: RawThreadInfo }, ): ?string { let mostRecent = null; for (const threadID in threadInfos) { const threadInfo = threadInfos[threadID]; if (threadInfo.currentUser.unread) { continue; } const threadMessageInfo = messageStore.threads[threadID]; if (!threadMessageInfo) { continue; } const mostRecentMessageTime = threadMessageInfo.messageIDs.length === 0 ? threadInfo.creationTime : messageStore.messages[threadMessageInfo.messageIDs[0]].time; if (mostRecent && mostRecent.time >= mostRecentMessageTime) { continue; } const topLevelThreadID = threadInfo.type === threadTypes.SIDEBAR ? threadInfo.parentThreadID : threadID; mostRecent = { threadID: topLevelThreadID, time: mostRecentMessageTime }; } return mostRecent ? mostRecent.threadID : null; } const mostRecentReadThreadSelector: ( state: BaseAppState<*>, ) => ?string = createSelector( (state: BaseAppState<*>) => state.messageStore, (state: BaseAppState<*>) => state.threadStore.threadInfos, mostRecentReadThread, ); export { threadInfoSelector, onScreenThreadInfos, onScreenEntryEditableThreadInfos, entryInfoSelector, currentDaysToEntries, childThreadInfos, unreadCount, unreadBackgroundCount, otherUsersButNoOtherAdmins, mostRecentReadThread, mostRecentReadThreadSelector, possiblyPendingThreadInfoSelector, sidebarInfoSelector, }; diff --git a/lib/selectors/user-selectors.js b/lib/selectors/user-selectors.js index e74342060..28c6a265a 100644 --- a/lib/selectors/user-selectors.js +++ b/lib/selectors/user-selectors.js @@ -1,184 +1,183 @@ // @flow +import _memoize from 'lodash/memoize'; +import { createSelector } from 'reselect'; + +import SearchIndex from '../shared/search-index'; +import { memberHasAdminPowers } from '../shared/thread-utils'; import type { BaseAppState } from '../types/redux-types'; +import { userRelationshipStatus } from '../types/relationship-types'; +import { + type RawThreadInfo, + type RelativeMemberInfo, +} from '../types/thread-types'; import type { UserInfos, RelativeUserInfo, AccountUserInfo, } from '../types/user-types'; -import { - type RawThreadInfo, - type RelativeMemberInfo, -} from '../types/thread-types'; -import { userRelationshipStatus } from '../types/relationship-types'; - -import { createSelector } from 'reselect'; -import _memoize from 'lodash/memoize'; - -import SearchIndex from '../shared/search-index'; -import { memberHasAdminPowers } from '../shared/thread-utils'; // Used for specific message payloads that include an array of user IDs, ie. // array of initial users, array of added users function userIDsToRelativeUserInfos( userIDs: string[], viewerID: ?string, userInfos: UserInfos, ): RelativeUserInfo[] { const relativeUserInfos = []; for (let userID of userIDs) { const username = userInfos[userID] ? userInfos[userID].username : null; if (userID === viewerID) { relativeUserInfos.unshift({ id: userID, username, isViewer: true, }); } else { relativeUserInfos.push({ id: userID, username, isViewer: false, }); } } return relativeUserInfos; } const emptyArray = []; // Includes current user at the start const baseRelativeMemberInfoSelectorForMembersOfThread = ( threadID: ?string, ) => { if (!threadID) { return () => emptyArray; } return createSelector( (state: BaseAppState<*>) => state.threadStore.threadInfos[threadID], (state: BaseAppState<*>) => state.currentUserInfo && state.currentUserInfo.id, (state: BaseAppState<*>) => state.userStore.userInfos, ( threadInfo: ?RawThreadInfo, currentUserID: ?string, userInfos: UserInfos, ): $ReadOnlyArray => { const relativeMemberInfos = []; if (!threadInfo) { return relativeMemberInfos; } const memberInfos = threadInfo.members; for (const memberInfo of memberInfos) { const isParentAdmin = memberHasAdminPowers(memberInfo); if (!memberInfo.role && !isParentAdmin) { continue; } const username = userInfos[memberInfo.id] ? userInfos[memberInfo.id].username : null; if (memberInfo.id === currentUserID) { relativeMemberInfos.unshift({ id: memberInfo.id, role: memberInfo.role, permissions: memberInfo.permissions, username, isViewer: true, }); } else { relativeMemberInfos.push({ id: memberInfo.id, role: memberInfo.role, permissions: memberInfo.permissions, username, isViewer: false, }); } } return relativeMemberInfos; }, ); }; const relativeMemberInfoSelectorForMembersOfThread: ( threadID: ?string, ) => (state: BaseAppState<*>) => $ReadOnlyArray = _memoize( baseRelativeMemberInfoSelectorForMembersOfThread, ); const userInfoSelectorForPotentialMembers: ( state: BaseAppState<*>, ) => { [id: string]: AccountUserInfo } = createSelector( (state: BaseAppState<*>) => state.userStore.userInfos, (state: BaseAppState<*>) => state.currentUserInfo && state.currentUserInfo.id, ( userInfos: UserInfos, currentUserID: ?string, ): { [id: string]: AccountUserInfo } => { const availableUsers: { [id: string]: AccountUserInfo } = {}; for (const id in userInfos) { const { username, relationshipStatus } = userInfos[id]; if (id === currentUserID || !username) { continue; } if ( relationshipStatus !== userRelationshipStatus.BLOCKED_VIEWER && relationshipStatus !== userRelationshipStatus.BOTH_BLOCKED ) { availableUsers[id] = { id, username, relationshipStatus }; } } return availableUsers; }, ); function searchIndexFromUserInfos(userInfos: { [id: string]: AccountUserInfo, }) { const searchIndex = new SearchIndex(); for (const id in userInfos) { searchIndex.addEntry(id, userInfos[id].username); } return searchIndex; } const userSearchIndexForPotentialMembers: ( state: BaseAppState<*>, ) => SearchIndex = createSelector( userInfoSelectorForPotentialMembers, searchIndexFromUserInfos, ); const isLoggedIn = (state: BaseAppState<*>) => !!( state.currentUserInfo && !state.currentUserInfo.anonymous && state.dataLoaded ); const userStoreSearchIndex: ( state: BaseAppState<*>, ) => SearchIndex = createSelector( (state: BaseAppState<*>) => state.userStore.userInfos, (userInfos: UserInfos) => { const searchIndex = new SearchIndex(); for (const id in userInfos) { const { username } = userInfos[id]; if (!username) { continue; } searchIndex.addEntry(id, username); } return searchIndex; }, ); export { userIDsToRelativeUserInfos, relativeMemberInfoSelectorForMembersOfThread, userInfoSelectorForPotentialMembers, userSearchIndexForPotentialMembers, isLoggedIn, userStoreSearchIndex, }; diff --git a/lib/shared/account-utils.js b/lib/shared/account-utils.js index 46da8e12e..23a54ee10 100644 --- a/lib/shared/account-utils.js +++ b/lib/shared/account-utils.js @@ -1,73 +1,72 @@ // @flow -import type { AppState } from '../types/redux-types'; -import type { CurrentUserInfo } from '../types/user-types'; -import type { PreRequestUserState } from '../types/session-types'; -import type { LogInActionSource } from '../types/account-types'; - import { cookieInvalidationResolutionAttempt, socketAuthErrorResolutionAttempt, } from '../actions/user-actions'; +import type { LogInActionSource } from '../types/account-types'; +import type { AppState } from '../types/redux-types'; +import type { PreRequestUserState } from '../types/session-types'; +import type { CurrentUserInfo } from '../types/user-types'; const oldValidUsernameRegexString = '[a-zA-Z0-9-_]+'; const validUsernameRegex = /^[a-zA-Z0-9][a-zA-Z0-9-_]{5,}$/; const oldValidUsernameRegex = new RegExp(`^${oldValidUsernameRegexString}$`); const validEmailRegex = new RegExp( /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+/.source + /@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?/.source + /(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/.source, ); function invalidSessionDowngrade( currentReduxState: AppState, actionCurrentUserInfo: ?CurrentUserInfo, preRequestUserState: ?PreRequestUserState, ): boolean { // If this action represents a session downgrade - oldState has a loggedIn // currentUserInfo, but the action has an anonymous one - then it is only // valid if the currentUserInfo used for the request matches what oldState // currently has. If the currentUserInfo in Redux has changed since the // request, and is currently loggedIn, then the session downgrade does not // apply to it. In this case we will simply swallow the action. const currentCurrentUserInfo = currentReduxState.currentUserInfo; return !!( currentCurrentUserInfo && !currentCurrentUserInfo.anonymous && // Note that an undefined actionCurrentUserInfo represents an action that // doesn't affect currentUserInfo, whereas a null one represents an action // that sets it to null (actionCurrentUserInfo === null || (actionCurrentUserInfo && actionCurrentUserInfo.anonymous)) && preRequestUserState && (preRequestUserState.currentUserInfo?.id !== currentCurrentUserInfo.id || preRequestUserState.cookie !== currentReduxState.cookie || preRequestUserState.sessionID !== currentReduxState.sessionID) ); } function invalidSessionRecovery( currentReduxState: AppState, actionCurrentUserInfo: CurrentUserInfo, source: ?LogInActionSource, ) { if ( source !== cookieInvalidationResolutionAttempt && source !== socketAuthErrorResolutionAttempt ) { return false; } return ( !currentReduxState.dataLoaded || currentReduxState.currentUserInfo?.id !== actionCurrentUserInfo.id ); } export { oldValidUsernameRegexString, validUsernameRegex, oldValidUsernameRegex, validEmailRegex, invalidSessionDowngrade, invalidSessionRecovery, }; diff --git a/lib/shared/entry-utils.js b/lib/shared/entry-utils.js index 87edc3245..9d308fce4 100644 --- a/lib/shared/entry-utils.js +++ b/lib/shared/entry-utils.js @@ -1,265 +1,264 @@ // @flow -import type { - RawEntryInfo, - EntryInfo, - CalendarQuery, -} from '../types/entry-types'; -import type { UserInfos } from '../types/user-types'; -import { calendarThreadFilterTypes } from '../types/filter-types'; - import invariant from 'invariant'; import _isEqual from 'lodash/fp/isEqual'; -import { dateString, getDate, dateFromString } from '../utils/date-utils'; import { filteredThreadIDs, nonThreadCalendarFilters, filterExists, } from '../selectors/calendar-filter-selectors'; +import type { + RawEntryInfo, + EntryInfo, + CalendarQuery, +} from '../types/entry-types'; +import { calendarThreadFilterTypes } from '../types/filter-types'; +import type { UserInfos } from '../types/user-types'; +import { dateString, getDate, dateFromString } from '../utils/date-utils'; type HasEntryIDs = { localID?: string, id?: string }; function entryKey(entryInfo: HasEntryIDs): string { if (entryInfo.localID) { return entryInfo.localID; } invariant(entryInfo.id, 'localID should exist if ID does not'); return entryInfo.id; } function entryID(entryInfo: HasEntryIDs): string { if (entryInfo.id) { return entryInfo.id; } invariant(entryInfo.localID, 'localID should exist if ID does not'); return entryInfo.localID; } function createEntryInfo( rawEntryInfo: RawEntryInfo, viewerID: ?string, userInfos: UserInfos, ): EntryInfo { const creatorInfo = userInfos[rawEntryInfo.creatorID]; return { id: rawEntryInfo.id, localID: rawEntryInfo.localID, threadID: rawEntryInfo.threadID, text: rawEntryInfo.text, year: rawEntryInfo.year, month: rawEntryInfo.month, day: rawEntryInfo.day, creationTime: rawEntryInfo.creationTime, creator: creatorInfo && creatorInfo.username, deleted: rawEntryInfo.deleted, }; } // Make sure EntryInfo is between startDate and endDate, and that if the // NOT_DELETED filter is active, the EntryInfo isn't deleted function rawEntryInfoWithinActiveRange( rawEntryInfo: RawEntryInfo, calendarQuery: CalendarQuery, ): boolean { const entryInfoDate = getDate( rawEntryInfo.year, rawEntryInfo.month, rawEntryInfo.day, ); const startDate = dateFromString(calendarQuery.startDate); const endDate = dateFromString(calendarQuery.endDate); if (entryInfoDate < startDate || entryInfoDate > endDate) { return false; } if ( rawEntryInfo.deleted && filterExists(calendarQuery.filters, calendarThreadFilterTypes.NOT_DELETED) ) { return false; } return true; } function rawEntryInfoWithinCalendarQuery( rawEntryInfo: RawEntryInfo, calendarQuery: CalendarQuery, ): boolean { if (!rawEntryInfoWithinActiveRange(rawEntryInfo, calendarQuery)) { return false; } const filterToThreadIDs = filteredThreadIDs(calendarQuery.filters); if (filterToThreadIDs && !filterToThreadIDs.has(rawEntryInfo.threadID)) { return false; } return true; } function filterRawEntryInfosByCalendarQuery( rawEntryInfos: { [id: string]: RawEntryInfo }, calendarQuery: CalendarQuery, ): { [id: string]: RawEntryInfo } { let filtered = false; const filteredRawEntryInfos = {}; for (let id in rawEntryInfos) { const rawEntryInfo = rawEntryInfos[id]; if (!rawEntryInfoWithinCalendarQuery(rawEntryInfo, calendarQuery)) { filtered = true; continue; } filteredRawEntryInfos[id] = rawEntryInfo; } return filtered ? filteredRawEntryInfos : rawEntryInfos; } function usersInRawEntryInfos( entryInfos: $ReadOnlyArray, ): string[] { const userIDs = new Set(); for (let entryInfo of entryInfos) { userIDs.add(entryInfo.creatorID); } return [...userIDs]; } // Note: fetchEntriesForSession expects that all of the CalendarQueries in the // resultant array either filter deleted entries or don't function calendarQueryDifference( oldCalendarQuery: CalendarQuery, newCalendarQuery: CalendarQuery, ): CalendarQuery[] { if (_isEqual(oldCalendarQuery)(newCalendarQuery)) { return []; } const deletedEntriesWereIncluded = filterExists( oldCalendarQuery.filters, calendarThreadFilterTypes.NOT_DELETED, ); const deletedEntriesAreIncluded = filterExists( newCalendarQuery.filters, calendarThreadFilterTypes.NOT_DELETED, ); if (!deletedEntriesWereIncluded && deletedEntriesAreIncluded) { // The new query includes all deleted entries, but the old one didn't. Since // we have no way to include ONLY deleted entries in a CalendarQuery, we // can't separate newCalendarQuery into a query for just deleted entries on // the old range, and a query for all entries on the full range. We'll have // to just query for the whole newCalendarQuery range directly. return [newCalendarQuery]; } const oldFilteredThreadIDs = filteredThreadIDs(oldCalendarQuery.filters); const newFilteredThreadIDs = filteredThreadIDs(newCalendarQuery.filters); if (oldFilteredThreadIDs && !newFilteredThreadIDs) { // The new query is for all thread IDs, but the old one had a THREAD_LIST. // Since we have no way to exclude particular thread IDs from a // CalendarQuery, we can't separate newCalendarQuery into a query for just // the new thread IDs on the old range, and a query for all the thread IDs // on the full range. We'll have to just query for the whole // newCalendarQuery range directly. return [newCalendarQuery]; } const difference = []; const oldStartDate = dateFromString(oldCalendarQuery.startDate); const oldEndDate = dateFromString(oldCalendarQuery.endDate); const newStartDate = dateFromString(newCalendarQuery.startDate); const newEndDate = dateFromString(newCalendarQuery.endDate); if ( oldFilteredThreadIDs && newFilteredThreadIDs && // This checks that there exists an intersection at all oldStartDate <= newEndDate && oldEndDate >= newStartDate ) { const newNotInOld = [...newFilteredThreadIDs].filter( (x) => !oldFilteredThreadIDs.has(x), ); if (newNotInOld.length > 0) { // In this case, we have added new threadIDs to the THREAD_LIST. // We should query the calendar range for these threads. const intersectionStartDate = oldStartDate < newStartDate ? newCalendarQuery.startDate : oldCalendarQuery.startDate; const intersectionEndDate = oldEndDate > newEndDate ? newCalendarQuery.endDate : oldCalendarQuery.endDate; difference.push({ startDate: intersectionStartDate, endDate: intersectionEndDate, filters: [ ...nonThreadCalendarFilters(newCalendarQuery.filters), { type: calendarThreadFilterTypes.THREAD_LIST, threadIDs: newNotInOld, }, ], }); } } if (newStartDate < oldStartDate) { const partialEndDate = new Date(oldStartDate.getTime()); partialEndDate.setDate(partialEndDate.getDate() - 1); difference.push({ filters: newCalendarQuery.filters, startDate: newCalendarQuery.startDate, endDate: dateString(partialEndDate), }); } if (newEndDate > oldEndDate) { const partialStartDate = new Date(oldEndDate.getTime()); partialStartDate.setDate(partialStartDate.getDate() + 1); difference.push({ filters: newCalendarQuery.filters, startDate: dateString(partialStartDate), endDate: newCalendarQuery.endDate, }); } return difference; } function serverEntryInfo(rawEntryInfo: RawEntryInfo): ?RawEntryInfo { const { id } = rawEntryInfo; if (!id) { return null; } const { localID, ...rest } = rawEntryInfo; return { ...rest }; // we only do this for Flow } function serverEntryInfosObject( array: $ReadOnlyArray, ): { [id: string]: RawEntryInfo } { const obj = {}; for (let rawEntryInfo of array) { const entryInfo = serverEntryInfo(rawEntryInfo); if (!entryInfo) { continue; } const { id } = entryInfo; invariant(id, 'should be set'); obj[id] = entryInfo; } return obj; } export { entryKey, entryID, createEntryInfo, rawEntryInfoWithinActiveRange, rawEntryInfoWithinCalendarQuery, filterRawEntryInfosByCalendarQuery, usersInRawEntryInfos, calendarQueryDifference, serverEntryInfo, serverEntryInfosObject, }; diff --git a/lib/shared/markdown.js b/lib/shared/markdown.js index 16bfc1011..c19f3c978 100644 --- a/lib/shared/markdown.js +++ b/lib/shared/markdown.js @@ -1,195 +1,195 @@ // @flow -import type { RelativeMemberInfo } from '../types/thread-types'; - import invariant from 'invariant'; +import type { RelativeMemberInfo } from '../types/thread-types'; + import { oldValidUsernameRegexString } from './account-utils'; // simple-markdown types type State = {| key?: string | number | void, inline?: ?boolean, [string]: any, |}; type Parser = (source: string, state?: ?State) => Array; type Capture = | (Array & { index: number }) | (Array & { index?: number }); type SingleASTNode = {| type: string, [string]: any, |}; type UnTypedASTNode = { [string]: any, ... }; const paragraphRegex = /^((?:[^\n]*)(?:\n|$))/; const paragraphStripTrailingNewlineRegex = /^([^\n]*)(?:\n|$)/; const headingRegex = /^ *(#{1,6}) ([^\n]+?)#* *(?![^\n])/; const headingStripFollowingNewlineRegex = /^ *(#{1,6}) ([^\n]+?)#* *(?:\n|$)/; const fenceRegex = /^(`{3,}|~{3,})[^\n]*\n([\s\S]*?\n)\1(?:\n|$)/; const fenceStripTrailingNewlineRegex = /^(`{3,}|~{3,})[^\n]*\n([\s\S]*?)\n\1(?:\n|$)/; const codeBlockRegex = /^(?: {4}[^\n]*\n*?)+(?!\n* {4}[^\n])(?:\n|$)/; const codeBlockStripTrailingNewlineRegex = /^((?: {4}[^\n]*\n*?)+)(?!\n* {4}[^\n])(?:\n|$)/; const blockQuoteRegex = /^( *>[^\n]+(?:\n[^\n]+)*)(?:\n|$)/; const blockQuoteStripFollowingNewlineRegex = /^( *>[^\n]+(?:\n[^\n]+)*)(?:\n|$){2}/; const urlRegex = /^(https?:\/\/[^\s<]+[^<.,:;"')\]\s])/i; const mentionRegex = new RegExp(`^(@(${oldValidUsernameRegexString}))\\b`); type JSONCapture = {| +[0]: string, +json: Object, |}; function jsonMatch(source: string): ?JSONCapture { if (!source.startsWith('{')) { return null; } let jsonString = ''; let counter = 0; for (let i = 0; i < source.length; i++) { const char = source[i]; jsonString += char; if (char === '{') { counter++; } else if (char === '}') { counter--; } if (counter === 0) { break; } } if (counter !== 0) { return null; } let json; try { json = JSON.parse(jsonString); } catch { return null; } if (!json || typeof json !== 'object') { return null; } return { [0]: jsonString, json, }; } function jsonPrint(capture: JSONCapture): string { return JSON.stringify(capture.json, null, ' '); } const listRegex = /^( *)([*+-]|\d+\.) ([\s\S]+?)(?:\n{2}|\s*\n*$)/; const listItemRegex = /^( *)([*+-]|\d+\.) [^\n]*(?:\n(?!\1(?:[*+-]|\d+\.) )[^\n]*)*(\n|$)/gm; const listItemPrefixRegex = /^( *)([*+-]|\d+\.) /; const listLookBehindRegex = /(?:^|\n)( *)$/; function matchList(source: string, state: State) { if (state.inline) { return null; } const prevCaptureStr = state.prevCapture ? state.prevCapture[0] : ''; const isStartOfLineCapture = listLookBehindRegex.exec(prevCaptureStr); if (!isStartOfLineCapture) { return null; } const fullSource = isStartOfLineCapture[1] + source; return listRegex.exec(fullSource); } // We've defined our own parse function for lists because simple-markdown // handles newlines differently. Outside of that our implementation is fairly // similar. For more details about list parsing works, take a look at the // comments in the simple-markdown package function parseList( capture: Capture, parse: Parser, state: State, ): UnTypedASTNode { const bullet = capture[2]; const ordered = bullet.length > 1; const start = ordered ? Number(bullet) : undefined; const items = capture[0].match(listItemRegex); let itemContent = null; if (items) { itemContent = items.map((item: string) => { const prefixCapture = listItemPrefixRegex.exec(item); const space = prefixCapture ? prefixCapture[0].length : 0; const spaceRegex = new RegExp('^ {1,' + space + '}', 'gm'); const content: string = item .replace(spaceRegex, '') .replace(listItemPrefixRegex, ''); // We're handling this different than simple-markdown - // each item is a paragraph return parse(content, state); }); } return { ordered: ordered, start: start, items: itemContent, }; } function matchMentions(members: $ReadOnlyArray) { const memberSet = new Set( members .filter(({ role }) => role) .map(({ username }) => username?.toLowerCase()) .filter(Boolean), ); const match = (source: string, state: State) => { if (!state.inline) { return null; } const result = mentionRegex.exec(source); if (!result) { return null; } const username = result[2]; invariant(username, 'mentionRegex should match two capture groups'); if (!memberSet.has(username.toLowerCase())) { return null; } return result; }; match.regex = mentionRegex; return match; } export { paragraphRegex, paragraphStripTrailingNewlineRegex, urlRegex, blockQuoteRegex, blockQuoteStripFollowingNewlineRegex, headingRegex, headingStripFollowingNewlineRegex, codeBlockRegex, codeBlockStripTrailingNewlineRegex, fenceRegex, fenceStripTrailingNewlineRegex, jsonMatch, jsonPrint, matchList, parseList, matchMentions, }; diff --git a/lib/shared/message-utils.js b/lib/shared/message-utils.js index f77dbe956..2636536c8 100644 --- a/lib/shared/message-utils.js +++ b/lib/shared/message-utils.js @@ -1,960 +1,960 @@ // @flow +import invariant from 'invariant'; +import _maxBy from 'lodash/fp/maxBy'; + +import { shimUploadURI, multimediaMessagePreview } from '../media/media-utils'; +import { userIDsToRelativeUserInfos } from '../selectors/user-selectors'; +import type { PlatformDetails } from '../types/device-types'; +import type { Media, Image, Video } from '../types/media-types'; import { type MessageInfo, type RawMessageInfo, type RobotextMessageInfo, type PreviewableMessageInfo, type TextMessageInfo, type MediaMessageInfo, type ImagesMessageInfo, type RawMultimediaMessageInfo, type MessageData, type MessageType, type MessageTruncationStatus, type RawImagesMessageInfo, type RawMediaMessageInfo, type MultimediaMessageData, type MediaMessageData, type ImagesMessageData, type MessageStore, messageTypes, messageTruncationStatus, } from '../types/message-types'; -import type { RelativeUserInfo, UserInfos } from '../types/user-types'; import type { ThreadInfo } from '../types/thread-types'; -import type { PlatformDetails } from '../types/device-types'; -import type { Media, Image, Video } from '../types/media-types'; - -import invariant from 'invariant'; -import _maxBy from 'lodash/fp/maxBy'; - +import type { RelativeUserInfo, UserInfos } from '../types/user-types'; import { prettyDate } from '../utils/date-utils'; -import { userIDsToRelativeUserInfos } from '../selectors/user-selectors'; -import { shimUploadURI, multimediaMessagePreview } from '../media/media-utils'; -import { stringForUser } from './user-utils'; + import { codeBlockRegex } from './markdown'; -import { hasMinCodeVersion } from '../shared/version-utils'; +import { stringForUser } from './user-utils'; +import { hasMinCodeVersion } from './version-utils'; // Prefers localID function messageKey(messageInfo: MessageInfo | RawMessageInfo): string { if (messageInfo.localID) { return messageInfo.localID; } invariant(messageInfo.id, 'localID should exist if ID does not'); return messageInfo.id; } // Prefers serverID function messageID(messageInfo: MessageInfo | RawMessageInfo): string { if (messageInfo.id) { return messageInfo.id; } invariant(messageInfo.localID, 'localID should exist if ID does not'); return messageInfo.localID; } function robotextForUser(user: RelativeUserInfo): string { if (user.isViewer) { return 'you'; } else if (user.username) { return `<${encodeURI(user.username)}|u${user.id}>`; } else { return 'anonymous'; } } function robotextForUsers(users: RelativeUserInfo[]): string { if (users.length === 1) { return robotextForUser(users[0]); } else if (users.length === 2) { return `${robotextForUser(users[0])} and ${robotextForUser(users[1])}`; } else if (users.length === 3) { return ( `${robotextForUser(users[0])}, ${robotextForUser(users[1])}, ` + `and ${robotextForUser(users[2])}` ); } else { return ( `${robotextForUser(users[0])}, ${robotextForUser(users[1])}, ` + `and ${users.length - 2} others` ); } } function encodedThreadEntity(threadID: string, text: string): string { return `<${text}|t${threadID}>`; } function robotextForMessageInfo( messageInfo: RobotextMessageInfo, threadInfo: ThreadInfo, ): string { const creator = robotextForUser(messageInfo.creator); if (messageInfo.type === messageTypes.CREATE_THREAD) { let text = `created ${encodedThreadEntity( messageInfo.threadID, 'this thread', )}`; const parentThread = messageInfo.initialThreadState.parentThreadInfo; if (parentThread) { text += ' as a child of ' + `<${encodeURI(parentThread.uiName)}|t${parentThread.id}>`; } if (messageInfo.initialThreadState.name) { text += ' with the name ' + `"${encodeURI(messageInfo.initialThreadState.name)}"`; } const users = messageInfo.initialThreadState.otherMembers; if (users.length !== 0) { const initialUsersString = robotextForUsers(users); text += ` and added ${initialUsersString}`; } return `${creator} ${text}`; } else if (messageInfo.type === messageTypes.ADD_MEMBERS) { const users = messageInfo.addedMembers; invariant(users.length !== 0, 'added who??'); const addedUsersString = robotextForUsers(users); return `${creator} added ${addedUsersString}`; } else if (messageInfo.type === messageTypes.CREATE_SUB_THREAD) { const childName = messageInfo.childThreadInfo.name; if (childName) { return ( `${creator} created a child thread` + ` named "<${encodeURI(childName)}|t${messageInfo.childThreadInfo.id}>"` ); } else { return ( `${creator} created a ` + `` ); } } else if (messageInfo.type === messageTypes.CHANGE_SETTINGS) { let value; if (messageInfo.field === 'color') { value = `<#${messageInfo.value}|c${messageInfo.threadID}>`; } else { value = messageInfo.value; } return ( `${creator} updated ` + `${encodedThreadEntity(messageInfo.threadID, 'the thread')}'s ` + `${messageInfo.field} to "${value}"` ); } else if (messageInfo.type === messageTypes.REMOVE_MEMBERS) { const users = messageInfo.removedMembers; invariant(users.length !== 0, 'removed who??'); const removedUsersString = robotextForUsers(users); return `${creator} removed ${removedUsersString}`; } else if (messageInfo.type === messageTypes.CHANGE_ROLE) { const users = messageInfo.members; invariant(users.length !== 0, 'changed whose role??'); const usersString = robotextForUsers(users); const verb = threadInfo.roles[messageInfo.newRole].isDefault ? 'removed' : 'added'; const noun = users.length === 1 ? 'an admin' : 'admins'; return `${creator} ${verb} ${usersString} as ${noun}`; } else if (messageInfo.type === messageTypes.LEAVE_THREAD) { return ( `${creator} left ` + encodedThreadEntity(messageInfo.threadID, 'this thread') ); } else if (messageInfo.type === messageTypes.JOIN_THREAD) { return ( `${creator} joined ` + encodedThreadEntity(messageInfo.threadID, 'this thread') ); } else if (messageInfo.type === messageTypes.CREATE_ENTRY) { const date = prettyDate(messageInfo.date); return ( `${creator} created an event scheduled for ${date}: ` + `"${messageInfo.text}"` ); } else if (messageInfo.type === messageTypes.EDIT_ENTRY) { const date = prettyDate(messageInfo.date); return ( `${creator} updated the text of an event scheduled for ` + `${date}: "${messageInfo.text}"` ); } else if (messageInfo.type === messageTypes.DELETE_ENTRY) { const date = prettyDate(messageInfo.date); return ( `${creator} deleted an event scheduled for ${date}: ` + `"${messageInfo.text}"` ); } else if (messageInfo.type === messageTypes.RESTORE_ENTRY) { const date = prettyDate(messageInfo.date); return ( `${creator} restored an event scheduled for ${date}: ` + `"${messageInfo.text}"` ); } else if (messageInfo.type === messageTypes.UPDATE_RELATIONSHIP) { const target = robotextForUser(messageInfo.target); if (messageInfo.operation === 'request_sent') { return `${creator} sent ${target} a friend request`; } else if (messageInfo.operation === 'request_accepted') { const targetPossessive = messageInfo.target.isViewer ? 'your' : `${target}'s`; return `${creator} accepted ${targetPossessive} friend request`; } invariant( false, `Invalid operation ${messageInfo.operation} ` + `of message with type ${messageInfo.type}`, ); } else if (messageInfo.type === messageTypes.UNSUPPORTED) { return `${creator} ${messageInfo.robotext}`; } invariant(false, `we're not aware of messageType ${messageInfo.type}`); } function robotextToRawString(robotext: string): string { return decodeURI(robotext.replace(/<([^<>|]+)\|[^<>|]+>/g, '$1')); } function createMessageInfo( rawMessageInfo: RawMessageInfo, viewerID: ?string, userInfos: UserInfos, threadInfos: { [id: string]: ThreadInfo }, ): ?MessageInfo { const creatorInfo = userInfos[rawMessageInfo.creatorID]; if (!creatorInfo) { return null; } if (rawMessageInfo.type === messageTypes.TEXT) { const messageInfo: TextMessageInfo = { type: messageTypes.TEXT, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, text: rawMessageInfo.text, }; if (rawMessageInfo.id) { messageInfo.id = rawMessageInfo.id; } if (rawMessageInfo.localID) { messageInfo.localID = rawMessageInfo.localID; } return messageInfo; } else if (rawMessageInfo.type === messageTypes.CREATE_THREAD) { const initialParentThreadID = rawMessageInfo.initialThreadState.parentThreadID; let parentThreadInfo = null; if (initialParentThreadID) { parentThreadInfo = threadInfos[initialParentThreadID]; } return { type: messageTypes.CREATE_THREAD, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, initialThreadState: { name: rawMessageInfo.initialThreadState.name, parentThreadInfo, type: rawMessageInfo.initialThreadState.type, color: rawMessageInfo.initialThreadState.color, otherMembers: userIDsToRelativeUserInfos( rawMessageInfo.initialThreadState.memberIDs.filter( (userID: string) => userID !== rawMessageInfo.creatorID, ), viewerID, userInfos, ), }, }; } else if (rawMessageInfo.type === messageTypes.ADD_MEMBERS) { const addedMembers = userIDsToRelativeUserInfos( rawMessageInfo.addedUserIDs, viewerID, userInfos, ); return { type: messageTypes.ADD_MEMBERS, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, addedMembers, }; } else if (rawMessageInfo.type === messageTypes.CREATE_SUB_THREAD) { const childThreadInfo = threadInfos[rawMessageInfo.childThreadID]; if (!childThreadInfo) { return null; } return { type: messageTypes.CREATE_SUB_THREAD, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, childThreadInfo, }; } else if (rawMessageInfo.type === messageTypes.CHANGE_SETTINGS) { return { type: messageTypes.CHANGE_SETTINGS, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, field: rawMessageInfo.field, value: rawMessageInfo.value, }; } else if (rawMessageInfo.type === messageTypes.REMOVE_MEMBERS) { const removedMembers = userIDsToRelativeUserInfos( rawMessageInfo.removedUserIDs, viewerID, userInfos, ); return { type: messageTypes.REMOVE_MEMBERS, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, removedMembers, }; } else if (rawMessageInfo.type === messageTypes.CHANGE_ROLE) { const members = userIDsToRelativeUserInfos( rawMessageInfo.userIDs, viewerID, userInfos, ); return { type: messageTypes.CHANGE_ROLE, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, members, newRole: rawMessageInfo.newRole, }; } else if (rawMessageInfo.type === messageTypes.LEAVE_THREAD) { return { type: messageTypes.LEAVE_THREAD, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, }; } else if (rawMessageInfo.type === messageTypes.JOIN_THREAD) { return { type: messageTypes.JOIN_THREAD, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, }; } else if (rawMessageInfo.type === messageTypes.CREATE_ENTRY) { return { type: messageTypes.CREATE_ENTRY, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, entryID: rawMessageInfo.entryID, date: rawMessageInfo.date, text: rawMessageInfo.text, }; } else if (rawMessageInfo.type === messageTypes.EDIT_ENTRY) { return { type: messageTypes.EDIT_ENTRY, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, entryID: rawMessageInfo.entryID, date: rawMessageInfo.date, text: rawMessageInfo.text, }; } else if (rawMessageInfo.type === messageTypes.DELETE_ENTRY) { return { type: messageTypes.DELETE_ENTRY, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, entryID: rawMessageInfo.entryID, date: rawMessageInfo.date, text: rawMessageInfo.text, }; } else if (rawMessageInfo.type === messageTypes.RESTORE_ENTRY) { return { type: messageTypes.RESTORE_ENTRY, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, entryID: rawMessageInfo.entryID, date: rawMessageInfo.date, text: rawMessageInfo.text, }; } else if (rawMessageInfo.type === messageTypes.UNSUPPORTED) { return { type: messageTypes.UNSUPPORTED, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, robotext: rawMessageInfo.robotext, unsupportedMessageInfo: rawMessageInfo.unsupportedMessageInfo, }; } else if (rawMessageInfo.type === messageTypes.IMAGES) { const messageInfo: ImagesMessageInfo = { type: messageTypes.IMAGES, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, media: rawMessageInfo.media, }; if (rawMessageInfo.id) { messageInfo.id = rawMessageInfo.id; } if (rawMessageInfo.localID) { messageInfo.localID = rawMessageInfo.localID; } return messageInfo; } else if (rawMessageInfo.type === messageTypes.MULTIMEDIA) { const messageInfo: MediaMessageInfo = { type: messageTypes.MULTIMEDIA, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, time: rawMessageInfo.time, media: rawMessageInfo.media, }; if (rawMessageInfo.id) { messageInfo.id = rawMessageInfo.id; } if (rawMessageInfo.localID) { messageInfo.localID = rawMessageInfo.localID; } return messageInfo; } else if (rawMessageInfo.type === messageTypes.UPDATE_RELATIONSHIP) { const target = userInfos[rawMessageInfo.targetID]; if (!target) { return null; } return { type: messageTypes.UPDATE_RELATIONSHIP, id: rawMessageInfo.id, threadID: rawMessageInfo.threadID, creator: { id: rawMessageInfo.creatorID, username: creatorInfo.username, isViewer: rawMessageInfo.creatorID === viewerID, }, target: { id: target.id, username: target.username, isViewer: target.id === viewerID, }, time: rawMessageInfo.time, operation: rawMessageInfo.operation, }; } invariant(false, `we're not aware of messageType ${rawMessageInfo.type}`); } function sortMessageInfoList( messageInfos: T[], ): T[] { return messageInfos.sort((a: T, b: T) => b.time - a.time); } function rawMessageInfoFromMessageData( messageData: MessageData, id: string, ): RawMessageInfo { if (messageData.type === messageTypes.TEXT) { return { ...messageData, id }; } else if (messageData.type === messageTypes.CREATE_THREAD) { return { ...messageData, id }; } else if (messageData.type === messageTypes.ADD_MEMBERS) { return { ...messageData, id }; } else if (messageData.type === messageTypes.CREATE_SUB_THREAD) { return { ...messageData, id }; } else if (messageData.type === messageTypes.CHANGE_SETTINGS) { return { ...messageData, id }; } else if (messageData.type === messageTypes.REMOVE_MEMBERS) { return { ...messageData, id }; } else if (messageData.type === messageTypes.CHANGE_ROLE) { return { ...messageData, id }; } else if (messageData.type === messageTypes.LEAVE_THREAD) { return { type: messageTypes.LEAVE_THREAD, id, threadID: messageData.threadID, creatorID: messageData.creatorID, time: messageData.time, }; } else if (messageData.type === messageTypes.JOIN_THREAD) { return { type: messageTypes.JOIN_THREAD, id, threadID: messageData.threadID, creatorID: messageData.creatorID, time: messageData.time, }; } else if (messageData.type === messageTypes.CREATE_ENTRY) { return { type: messageTypes.CREATE_ENTRY, id, threadID: messageData.threadID, creatorID: messageData.creatorID, time: messageData.time, entryID: messageData.entryID, date: messageData.date, text: messageData.text, }; } else if (messageData.type === messageTypes.EDIT_ENTRY) { return { type: messageTypes.EDIT_ENTRY, id, threadID: messageData.threadID, creatorID: messageData.creatorID, time: messageData.time, entryID: messageData.entryID, date: messageData.date, text: messageData.text, }; } else if (messageData.type === messageTypes.DELETE_ENTRY) { return { type: messageTypes.DELETE_ENTRY, id, threadID: messageData.threadID, creatorID: messageData.creatorID, time: messageData.time, entryID: messageData.entryID, date: messageData.date, text: messageData.text, }; } else if (messageData.type === messageTypes.RESTORE_ENTRY) { return { type: messageTypes.RESTORE_ENTRY, id, threadID: messageData.threadID, creatorID: messageData.creatorID, time: messageData.time, entryID: messageData.entryID, date: messageData.date, text: messageData.text, }; } else if (messageData.type === messageTypes.IMAGES) { return ({ ...messageData, id }: RawImagesMessageInfo); } else if (messageData.type === messageTypes.MULTIMEDIA) { return ({ ...messageData, id }: RawMediaMessageInfo); } else if (messageData.type === messageTypes.UPDATE_RELATIONSHIP) { return { ...messageData, id }; } else { invariant(false, `we're not aware of messageType ${messageData.type}`); } } function mostRecentMessageTimestamp( messageInfos: RawMessageInfo[], previousTimestamp: number, ): number { if (messageInfos.length === 0) { return previousTimestamp; } return _maxBy('time')(messageInfos).time; } function messageTypeGeneratesNotifs(type: MessageType) { return ( type !== messageTypes.JOIN_THREAD && type !== messageTypes.LEAVE_THREAD && type !== messageTypes.ADD_MEMBERS && type !== messageTypes.REMOVE_MEMBERS ); } function splitRobotext(robotext: string) { return robotext.split(/(<[^<>|]+\|[^<>|]+>)/g); } const robotextEntityRegex = /<([^<>|]+)\|([^<>|]+)>/; function parseRobotextEntity(robotextPart: string) { const entityParts = robotextPart.match(robotextEntityRegex); invariant(entityParts && entityParts[1], 'malformed robotext'); const rawText = decodeURI(entityParts[1]); const entityType = entityParts[2].charAt(0); const id = entityParts[2].substr(1); return { rawText, entityType, id }; } function usersInMessageInfos( messageInfos: $ReadOnlyArray, ): string[] { const userIDs = new Set(); for (let messageInfo of messageInfos) { if (messageInfo.creatorID) { userIDs.add(messageInfo.creatorID); } else if (messageInfo.creator) { userIDs.add(messageInfo.creator.id); } } return [...userIDs]; } function combineTruncationStatuses( first: MessageTruncationStatus, second: ?MessageTruncationStatus, ): MessageTruncationStatus { if ( first === messageTruncationStatus.EXHAUSTIVE || second === messageTruncationStatus.EXHAUSTIVE ) { return messageTruncationStatus.EXHAUSTIVE; } else if ( first === messageTruncationStatus.UNCHANGED && second !== null && second !== undefined ) { return second; } else { return first; } } function shimUnsupportedRawMessageInfos( rawMessageInfos: $ReadOnlyArray, platformDetails: ?PlatformDetails, ): RawMessageInfo[] { if (platformDetails && platformDetails.platform === 'web') { return [...rawMessageInfos]; } return rawMessageInfos.map((rawMessageInfo) => { if (rawMessageInfo.type === messageTypes.IMAGES) { const shimmedRawMessageInfo = shimMediaMessageInfo( rawMessageInfo, platformDetails, ); if (hasMinCodeVersion(platformDetails, 30)) { return shimmedRawMessageInfo; } const { id } = shimmedRawMessageInfo; invariant(id !== null && id !== undefined, 'id should be set on server'); return { type: messageTypes.UNSUPPORTED, id, threadID: shimmedRawMessageInfo.threadID, creatorID: shimmedRawMessageInfo.creatorID, time: shimmedRawMessageInfo.time, robotext: multimediaMessagePreview(shimmedRawMessageInfo), unsupportedMessageInfo: shimmedRawMessageInfo, }; } else if (rawMessageInfo.type === messageTypes.MULTIMEDIA) { const shimmedRawMessageInfo = shimMediaMessageInfo( rawMessageInfo, platformDetails, ); // TODO figure out first native codeVersion supporting video playback if (hasMinCodeVersion(platformDetails, 62)) { return shimmedRawMessageInfo; } const { id } = shimmedRawMessageInfo; invariant(id !== null && id !== undefined, 'id should be set on server'); return { type: messageTypes.UNSUPPORTED, id, threadID: shimmedRawMessageInfo.threadID, creatorID: shimmedRawMessageInfo.creatorID, time: shimmedRawMessageInfo.time, robotext: multimediaMessagePreview(shimmedRawMessageInfo), unsupportedMessageInfo: shimmedRawMessageInfo, }; } return rawMessageInfo; }); } function shimMediaMessageInfo( rawMessageInfo: RawMultimediaMessageInfo, platformDetails: ?PlatformDetails, ): RawMultimediaMessageInfo { if (rawMessageInfo.type === messageTypes.IMAGES) { let uriChanged = false; const newMedia: Image[] = []; for (let singleMedia of rawMessageInfo.media) { const shimmedURI = shimUploadURI(singleMedia.uri, platformDetails); if (shimmedURI === singleMedia.uri) { newMedia.push(singleMedia); } else { newMedia.push(({ ...singleMedia, uri: shimmedURI }: Image)); uriChanged = true; } } if (!uriChanged) { return rawMessageInfo; } return ({ ...rawMessageInfo, media: newMedia, }: RawImagesMessageInfo); } else { let uriChanged = false; const newMedia: Media[] = []; for (let singleMedia of rawMessageInfo.media) { const shimmedURI = shimUploadURI(singleMedia.uri, platformDetails); if (shimmedURI === singleMedia.uri) { newMedia.push(singleMedia); } else if (singleMedia.type === 'photo') { newMedia.push(({ ...singleMedia, uri: shimmedURI }: Image)); uriChanged = true; } else { newMedia.push(({ ...singleMedia, uri: shimmedURI }: Video)); uriChanged = true; } } if (!uriChanged) { return rawMessageInfo; } return ({ ...rawMessageInfo, media: newMedia, }: RawMediaMessageInfo); } } function messagePreviewText( messageInfo: PreviewableMessageInfo, threadInfo: ThreadInfo, ): string { if ( messageInfo.type === messageTypes.IMAGES || messageInfo.type === messageTypes.MULTIMEDIA ) { const creator = stringForUser(messageInfo.creator); const preview = multimediaMessagePreview(messageInfo); return `${creator} ${preview}`; } return robotextToRawString(robotextForMessageInfo(messageInfo, threadInfo)); } type MediaMessageDataCreationInput = $ReadOnly<{ threadID: string, creatorID: string, media: $ReadOnlyArray, localID?: ?string, time?: ?number, ... }>; function createMediaMessageData( input: MediaMessageDataCreationInput, ): MultimediaMessageData { let allMediaArePhotos = true; const photoMedia = []; for (let singleMedia of input.media) { if (singleMedia.type === 'video') { allMediaArePhotos = false; break; } else { photoMedia.push(singleMedia); } } const { localID, threadID, creatorID } = input; const time = input.time ? input.time : Date.now(); let messageData; if (allMediaArePhotos) { messageData = ({ type: messageTypes.IMAGES, threadID, creatorID, time, media: photoMedia, }: ImagesMessageData); } else { messageData = ({ type: messageTypes.MULTIMEDIA, threadID, creatorID, time, media: input.media, }: MediaMessageData); } if (localID) { messageData.localID = localID; } return messageData; } type MediaMessageInfoCreationInput = $ReadOnly<{ ...$Exact, id?: ?string, }>; function createMediaMessageInfo( input: MediaMessageInfoCreationInput, ): RawMultimediaMessageInfo { const messageData = createMediaMessageData(input); // This conditional is for Flow let rawMessageInfo; if (messageData.type === messageTypes.IMAGES) { rawMessageInfo = ({ ...messageData, type: messageTypes.IMAGES, }: RawImagesMessageInfo); } else { rawMessageInfo = ({ ...messageData, type: messageTypes.MULTIMEDIA, }: RawMediaMessageInfo); } if (input.id) { rawMessageInfo.id = input.id; } return rawMessageInfo; } function stripLocalIDs( input: $ReadOnlyArray, ): RawMessageInfo[] { const output = []; for (let rawMessageInfo of input) { if ( rawMessageInfo.localID === null || rawMessageInfo.localID === undefined ) { output.push(rawMessageInfo); continue; } invariant( rawMessageInfo.id, 'serverID should be set if localID is being stripped', ); if (rawMessageInfo.type === messageTypes.TEXT) { const { localID, ...rest } = rawMessageInfo; output.push({ ...rest }); } else if (rawMessageInfo.type === messageTypes.IMAGES) { const { localID, ...rest } = rawMessageInfo; output.push(({ ...rest }: RawImagesMessageInfo)); } else if (rawMessageInfo.type === messageTypes.MULTIMEDIA) { const { localID, ...rest } = rawMessageInfo; output.push(({ ...rest }: RawMediaMessageInfo)); } else { invariant( false, `message ${rawMessageInfo.id} of type ${rawMessageInfo.type} ` + `unexpectedly has localID`, ); } } return output; } // Normally we call trim() to remove whitespace at the beginning and end of each // message. However, our Markdown parser supports a "codeBlock" format where the // user can indent each line to indicate a code block. If we match the // corresponding RegEx, we'll only trim whitespace off the end. function trimMessage(message: string) { message = message.replace(/^\n*/, ''); return codeBlockRegex.exec(message) ? message.trimEnd() : message.trim(); } function createMessageReply(message: string) { // add `>` to each line to include empty lines in the quote const quotedMessage = message.replace(/^/gm, '> '); return quotedMessage + '\n\n'; } function getMostRecentNonLocalMessageID( threadInfo: ThreadInfo, messageStore: MessageStore, ): ?string { const thread = messageStore.threads[threadInfo.id]; return thread?.messageIDs.find((id) => !id.startsWith('local')); } export { messageKey, messageID, robotextForMessageInfo, robotextToRawString, createMessageInfo, sortMessageInfoList, rawMessageInfoFromMessageData, mostRecentMessageTimestamp, messageTypeGeneratesNotifs, splitRobotext, parseRobotextEntity, usersInMessageInfos, combineTruncationStatuses, shimUnsupportedRawMessageInfos, messagePreviewText, createMediaMessageData, createMediaMessageInfo, stripLocalIDs, trimMessage, createMessageReply, getMostRecentNonLocalMessageID, }; diff --git a/lib/shared/notif-utils.js b/lib/shared/notif-utils.js index dfacd1d20..ff4d203b2 100644 --- a/lib/shared/notif-utils.js +++ b/lib/shared/notif-utils.js @@ -1,532 +1,532 @@ // @flow +import invariant from 'invariant'; + +import { contentStringForMediaArray } from '../media/media-utils'; import { type MessageInfo, type RawMessageInfo, type RobotextMessageInfo, type MessageType, messageTypes, } from '../types/message-types'; import type { ThreadInfo } from '../types/thread-types'; import type { RelativeUserInfo } from '../types/user-types'; - -import invariant from 'invariant'; +import { prettyDate } from '../utils/date-utils'; +import { values } from '../utils/objects'; +import { pluralize } from '../utils/text-utils'; import { robotextForMessageInfo, robotextToRawString } from './message-utils'; import { threadIsGroupChat } from './thread-utils'; import { stringForUser } from './user-utils'; -import { prettyDate } from '../utils/date-utils'; -import { pluralize } from '../utils/text-utils'; -import { values } from '../utils/objects'; -import { contentStringForMediaArray } from '../media/media-utils'; type NotifTexts = {| merged: string, body: string, title: string, prefix?: string, |}; function notifTextsForMessageInfo( messageInfos: MessageInfo[], threadInfo: ThreadInfo, ): NotifTexts { const fullNotifTexts = fullNotifTextsForMessageInfo(messageInfos, threadInfo); const result: NotifTexts = { merged: trimNotifText(fullNotifTexts.merged, 300), body: trimNotifText(fullNotifTexts.body, 300), title: trimNotifText(fullNotifTexts.title, 100), }; if (fullNotifTexts.prefix) { result.prefix = trimNotifText(fullNotifTexts.prefix, 50); } return result; } function trimNotifText(text: string, maxLength: number): string { if (text.length <= maxLength) { return text; } return text.substr(0, maxLength - 3) + '...'; } const notifTextForSubthreadCreation = ( creator: RelativeUserInfo, parentThreadInfo: ThreadInfo, childThreadName: ?string, childThreadUIName: string, ) => { const prefix = stringForUser(creator); let body = `created a new thread`; if (parentThreadInfo.name) { body += ` in ${parentThreadInfo.name}`; } let merged = `${prefix} ${body}`; if (childThreadName) { merged += ` called "${childThreadName}"`; } return { merged, body, title: childThreadUIName, prefix, }; }; function notifThreadName(threadInfo: ThreadInfo): string { if (threadInfo.name) { return threadInfo.name; } else { return 'your thread'; } } function assertSingleMessageInfo( messageInfos: $ReadOnlyArray, ): MessageInfo { if (messageInfos.length === 0) { throw new Error('expected single MessageInfo, but none present!'); } else if (messageInfos.length !== 1) { const messageIDs = messageInfos.map((messageInfo) => messageInfo.id); console.log( 'expected single MessageInfo, but there are multiple! ' + messageIDs.join(', '), ); } return messageInfos[0]; } function mostRecentMessageInfoType( messageInfos: $ReadOnlyArray, ): MessageType { if (messageInfos.length === 0) { throw new Error('expected MessageInfo, but none present!'); } return messageInfos[0].type; } function fullNotifTextsForMessageInfo( messageInfos: MessageInfo[], threadInfo: ThreadInfo, ): NotifTexts { const mostRecentType = mostRecentMessageInfoType(messageInfos); if (mostRecentType === messageTypes.TEXT) { const messageInfo = assertSingleMessageInfo(messageInfos); invariant( messageInfo.type === messageTypes.TEXT, 'messageInfo should be messageTypes.TEXT!', ); if (!threadInfo.name && !threadIsGroupChat(threadInfo)) { return { merged: `${threadInfo.uiName}: ${messageInfo.text}`, body: messageInfo.text, title: threadInfo.uiName, }; } else { const userString = stringForUser(messageInfo.creator); const threadName = notifThreadName(threadInfo); return { merged: `${userString} to ${threadName}: ${messageInfo.text}`, body: messageInfo.text, title: threadInfo.uiName, prefix: `${userString}:`, }; } } else if (mostRecentType === messageTypes.CREATE_THREAD) { const messageInfo = assertSingleMessageInfo(messageInfos); invariant( messageInfo.type === messageTypes.CREATE_THREAD, 'messageInfo should be messageTypes.CREATE_THREAD!', ); const parentThreadInfo = messageInfo.initialThreadState.parentThreadInfo; if (parentThreadInfo) { return notifTextForSubthreadCreation( messageInfo.creator, parentThreadInfo, messageInfo.initialThreadState.name, threadInfo.uiName, ); } const prefix = stringForUser(messageInfo.creator); const body = 'created a new thread'; let merged = `${prefix} ${body}`; if (messageInfo.initialThreadState.name) { merged += ` called "${messageInfo.initialThreadState.name}"`; } return { merged, body, title: threadInfo.uiName, prefix, }; } else if (mostRecentType === messageTypes.ADD_MEMBERS) { const addedMembersObject = {}; for (let messageInfo of messageInfos) { invariant( messageInfo.type === messageTypes.ADD_MEMBERS, 'messageInfo should be messageTypes.ADD_MEMBERS!', ); for (let member of messageInfo.addedMembers) { addedMembersObject[member.id] = member; } } const addedMembers = values(addedMembersObject); const mostRecentMessageInfo = messageInfos[0]; invariant( mostRecentMessageInfo.type === messageTypes.ADD_MEMBERS, 'messageInfo should be messageTypes.ADD_MEMBERS!', ); const mergedMessageInfo = { ...mostRecentMessageInfo, addedMembers }; const robotext = strippedRobotextForMessageInfo( mergedMessageInfo, threadInfo, ); const merged = `${robotext} to ${notifThreadName(threadInfo)}`; return { merged, title: threadInfo.uiName, body: robotext, }; } else if (mostRecentType === messageTypes.CREATE_SUB_THREAD) { const messageInfo = assertSingleMessageInfo(messageInfos); invariant( messageInfo.type === messageTypes.CREATE_SUB_THREAD, 'messageInfo should be messageTypes.CREATE_SUB_THREAD!', ); return notifTextForSubthreadCreation( messageInfo.creator, threadInfo, messageInfo.childThreadInfo.name, messageInfo.childThreadInfo.uiName, ); } else if (mostRecentType === messageTypes.REMOVE_MEMBERS) { const removedMembersObject = {}; for (let messageInfo of messageInfos) { invariant( messageInfo.type === messageTypes.REMOVE_MEMBERS, 'messageInfo should be messageTypes.REMOVE_MEMBERS!', ); for (let member of messageInfo.removedMembers) { removedMembersObject[member.id] = member; } } const removedMembers = values(removedMembersObject); const mostRecentMessageInfo = messageInfos[0]; invariant( mostRecentMessageInfo.type === messageTypes.REMOVE_MEMBERS, 'messageInfo should be messageTypes.REMOVE_MEMBERS!', ); const mergedMessageInfo = { ...mostRecentMessageInfo, removedMembers }; const robotext = strippedRobotextForMessageInfo( mergedMessageInfo, threadInfo, ); const merged = `${robotext} from ${notifThreadName(threadInfo)}`; return { merged, title: threadInfo.uiName, body: robotext, }; } else if (mostRecentType === messageTypes.CHANGE_ROLE) { const membersObject = {}; for (let messageInfo of messageInfos) { invariant( messageInfo.type === messageTypes.CHANGE_ROLE, 'messageInfo should be messageTypes.CHANGE_ROLE!', ); for (let member of messageInfo.members) { membersObject[member.id] = member; } } const members = values(membersObject); const mostRecentMessageInfo = messageInfos[0]; invariant( mostRecentMessageInfo.type === messageTypes.CHANGE_ROLE, 'messageInfo should be messageTypes.CHANGE_ROLE!', ); const mergedMessageInfo = { ...mostRecentMessageInfo, members }; const robotext = strippedRobotextForMessageInfo( mergedMessageInfo, threadInfo, ); const merged = `${robotext} from ${notifThreadName(threadInfo)}`; return { merged, title: threadInfo.uiName, body: robotext, }; } else if (mostRecentType === messageTypes.LEAVE_THREAD) { const leaverBeavers = {}; for (let messageInfo of messageInfos) { invariant( messageInfo.type === messageTypes.LEAVE_THREAD, 'messageInfo should be messageTypes.LEAVE_THREAD!', ); leaverBeavers[messageInfo.creator.id] = messageInfo.creator; } const leavers = values(leaverBeavers); const leaversString = pluralize(leavers.map(stringForUser)); const body = `${leaversString} left`; const merged = `${body} ${notifThreadName(threadInfo)}`; return { merged, title: threadInfo.uiName, body, }; } else if (mostRecentType === messageTypes.JOIN_THREAD) { const joinerArray = {}; for (let messageInfo of messageInfos) { invariant( messageInfo.type === messageTypes.JOIN_THREAD, 'messageInfo should be messageTypes.JOIN_THREAD!', ); joinerArray[messageInfo.creator.id] = messageInfo.creator; } const joiners = values(joinerArray); const joinersString = pluralize(joiners.map(stringForUser)); const body = `${joinersString} joined`; const merged = `${body} ${notifThreadName(threadInfo)}`; return { merged, title: threadInfo.uiName, body, }; } else if ( mostRecentType === messageTypes.CREATE_ENTRY || mostRecentType === messageTypes.EDIT_ENTRY ) { const hasCreateEntry = messageInfos.some( (messageInfo) => messageInfo.type === messageTypes.CREATE_ENTRY, ); const messageInfo = messageInfos[0]; if (!hasCreateEntry) { invariant( messageInfo.type === messageTypes.EDIT_ENTRY, 'messageInfo should be messageTypes.EDIT_ENTRY!', ); const body = `updated the text of an event in ` + `${notifThreadName(threadInfo)} scheduled for ` + `${prettyDate(messageInfo.date)}: "${messageInfo.text}"`; const prefix = stringForUser(messageInfo.creator); const merged = `${prefix} ${body}`; return { merged, title: threadInfo.uiName, body, prefix, }; } invariant( messageInfo.type === messageTypes.CREATE_ENTRY || messageInfo.type === messageTypes.EDIT_ENTRY, 'messageInfo should be messageTypes.CREATE_ENTRY/EDIT_ENTRY!', ); const prefix = stringForUser(messageInfo.creator); const body = `created an event in ${notifThreadName(threadInfo)} ` + `scheduled for ${prettyDate(messageInfo.date)}: "${messageInfo.text}"`; const merged = `${prefix} ${body}`; return { merged, title: threadInfo.uiName, body, prefix, }; } else if (mostRecentType === messageTypes.DELETE_ENTRY) { const messageInfo = assertSingleMessageInfo(messageInfos); invariant( messageInfo.type === messageTypes.DELETE_ENTRY, 'messageInfo should be messageTypes.DELETE_ENTRY!', ); const prefix = stringForUser(messageInfo.creator); const body = `deleted an event in ${notifThreadName(threadInfo)} ` + `scheduled for ${prettyDate(messageInfo.date)}: "${messageInfo.text}"`; const merged = `${prefix} ${body}`; return { merged, title: threadInfo.uiName, body, prefix, }; } else if (mostRecentType === messageTypes.RESTORE_ENTRY) { const messageInfo = assertSingleMessageInfo(messageInfos); invariant( messageInfo.type === messageTypes.RESTORE_ENTRY, 'messageInfo should be messageTypes.RESTORE_ENTRY!', ); const prefix = stringForUser(messageInfo.creator); const body = `restored an event in ${notifThreadName(threadInfo)} ` + `scheduled for ${prettyDate(messageInfo.date)}: "${messageInfo.text}"`; const merged = `${prefix} ${body}`; return { merged, title: threadInfo.uiName, body, prefix, }; } else if (mostRecentType === messageTypes.CHANGE_SETTINGS) { const mostRecentMessageInfo = messageInfos[0]; invariant( mostRecentMessageInfo.type === messageTypes.CHANGE_SETTINGS, 'messageInfo should be messageTypes.CHANGE_SETTINGS!', ); const body = strippedRobotextForMessageInfo( mostRecentMessageInfo, threadInfo, ); return { merged: body, title: threadInfo.uiName, body, }; } else if ( mostRecentType === messageTypes.IMAGES || mostRecentType === messageTypes.MULTIMEDIA ) { const media = []; for (let messageInfo of messageInfos) { invariant( messageInfo.type === messageTypes.IMAGES || messageInfo.type === messageTypes.MULTIMEDIA, 'messageInfo should be multimedia type!', ); for (let singleMedia of messageInfo.media) { media.push(singleMedia); } } const contentString = contentStringForMediaArray(media); const userString = stringForUser(messageInfos[0].creator); let body, merged; if (!threadInfo.name && !threadIsGroupChat(threadInfo)) { body = `sent you ${contentString}`; merged = body; } else { body = `sent ${contentString}`; const threadName = notifThreadName(threadInfo); merged = `${body} to ${threadName}`; } merged = `${userString} ${merged}`; return { merged, body, title: threadInfo.uiName, prefix: userString, }; } else if (mostRecentType === messageTypes.UPDATE_RELATIONSHIP) { const messageInfo = assertSingleMessageInfo(messageInfos); const prefix = stringForUser(messageInfo.creator); const title = threadInfo.uiName; const body = messageInfo.operation === 'request_sent' ? 'sent you a friend request' : 'accepted your friend request'; const merged = `${prefix} ${body}`; return { merged, body, title, prefix, }; } else { invariant(false, `we're not aware of messageType ${mostRecentType}`); } } function strippedRobotextForMessageInfo( messageInfo: RobotextMessageInfo, threadInfo: ThreadInfo, ): string { const robotext = robotextForMessageInfo(messageInfo, threadInfo); const threadEntityRegex = new RegExp(`<[^<>\\|]+\\|t${threadInfo.id}>`); const threadMadeExplicit = robotext.replace( threadEntityRegex, notifThreadName(threadInfo), ); return robotextToRawString(threadMadeExplicit); } const joinResult = (...keys: (string | number)[]) => keys.join('|'); function notifCollapseKeyForRawMessageInfo( rawMessageInfo: RawMessageInfo, ): ?string { if ( rawMessageInfo.type === messageTypes.ADD_MEMBERS || rawMessageInfo.type === messageTypes.REMOVE_MEMBERS ) { return joinResult( rawMessageInfo.type, rawMessageInfo.threadID, rawMessageInfo.creatorID, ); } else if ( rawMessageInfo.type === messageTypes.IMAGES || rawMessageInfo.type === messageTypes.MULTIMEDIA ) { // We use the legacy constant here to collapse both types into one return joinResult( messageTypes.IMAGES, rawMessageInfo.threadID, rawMessageInfo.creatorID, ); } else if (rawMessageInfo.type === messageTypes.CHANGE_SETTINGS) { return joinResult( rawMessageInfo.type, rawMessageInfo.threadID, rawMessageInfo.creatorID, rawMessageInfo.field, ); } else if (rawMessageInfo.type === messageTypes.CHANGE_ROLE) { return joinResult( rawMessageInfo.type, rawMessageInfo.threadID, rawMessageInfo.creatorID, rawMessageInfo.newRole, ); } else if ( rawMessageInfo.type === messageTypes.JOIN_THREAD || rawMessageInfo.type === messageTypes.LEAVE_THREAD ) { return joinResult(rawMessageInfo.type, rawMessageInfo.threadID); } else if ( rawMessageInfo.type === messageTypes.CREATE_ENTRY || rawMessageInfo.type === messageTypes.EDIT_ENTRY ) { return joinResult(rawMessageInfo.creatorID, rawMessageInfo.entryID); } else { return null; } } type Unmerged = $ReadOnly<{ body: string, title: string, prefix?: string, ... }>; type Merged = {| body: string, title: string, |}; function mergePrefixIntoBody(unmerged: Unmerged): Merged { const { body, title, prefix } = unmerged; const merged = prefix ? `${prefix} ${body}` : body; return { body: merged, title }; } export { notifTextsForMessageInfo, notifCollapseKeyForRawMessageInfo, mergePrefixIntoBody, }; diff --git a/lib/shared/report-utils.js b/lib/shared/report-utils.js index 7a4ae153e..bccc0ed7e 100644 --- a/lib/shared/report-utils.js +++ b/lib/shared/report-utils.js @@ -1,30 +1,30 @@ // @flow -import { - type ClientInconsistencyResponse, - serverRequestTypes, -} from '../types/request-types'; +import invariant from 'invariant'; + import { type ClientReportCreationRequest, reportTypes, } from '../types/report-types'; - -import invariant from 'invariant'; +import { + type ClientInconsistencyResponse, + serverRequestTypes, +} from '../types/request-types'; function inconsistencyResponsesToReports( responses: $ReadOnlyArray, ): ClientReportCreationRequest[] { return responses.map((response) => { if (response.type === serverRequestTypes.THREAD_INCONSISTENCY) { const { type, ...rest } = response; return { ...rest, type: reportTypes.THREAD_INCONSISTENCY }; } else if (response.type === serverRequestTypes.ENTRY_INCONSISTENCY) { const { type, ...rest } = response; return { ...rest, type: reportTypes.ENTRY_INCONSISTENCY }; } else { invariant(false, `unexpected serverRequestType ${response.type}`); } }); } export { inconsistencyResponsesToReports }; diff --git a/lib/shared/search-utils.js b/lib/shared/search-utils.js index ec0bea5cd..ee188c2d1 100644 --- a/lib/shared/search-utils.js +++ b/lib/shared/search-utils.js @@ -1,95 +1,95 @@ // @flow -import type { AccountUserInfo, UserListItem } from '../types/user-types'; -import type { ThreadInfo } from '../types/thread-types'; import { userRelationshipStatus } from '../types/relationship-types'; +import type { ThreadInfo } from '../types/thread-types'; +import type { AccountUserInfo, UserListItem } from '../types/user-types'; import SearchIndex from './search-index'; import { userIsMember } from './thread-utils'; function getPotentialMemberItems( text: string, userInfos: { [id: string]: AccountUserInfo }, searchIndex: SearchIndex, excludeUserIDs: $ReadOnlyArray, parentThreadInfo: ?ThreadInfo, ): UserListItem[] { let results = []; const appendUserInfo = (userInfo: AccountUserInfo) => { if (!excludeUserIDs.includes(userInfo.id)) { results.push({ ...userInfo, isMemberOfParentThread: userIsMember(parentThreadInfo, userInfo.id), }); } }; if (text === '') { for (const id in userInfos) { appendUserInfo(userInfos[id]); } } else { const ids = searchIndex.getSearchResults(text); for (const id of ids) { appendUserInfo(userInfos[id]); } } if (text === '') { results = results.filter((userInfo) => parentThreadInfo ? userInfo.isMemberOfParentThread : userInfo.relationshipStatus === userRelationshipStatus.FRIEND, ); } const nonFriends = []; const blockedUsers = []; const friendsAndParentMembers = []; for (const userResult of results) { const relationshipStatus = userResult.relationshipStatus; if (userResult.isMemberOfParentThread) { friendsAndParentMembers.unshift(userResult); } else if (relationshipStatus === userRelationshipStatus.FRIEND) { friendsAndParentMembers.push(userResult); } else if ( relationshipStatus === userRelationshipStatus.BLOCKED_BY_VIEWER ) { blockedUsers.push(userResult); } else { nonFriends.push(userResult); } } const sortedResults = friendsAndParentMembers .concat(nonFriends) .concat(blockedUsers); return sortedResults.map( ({ isMemberOfParentThread, relationshipStatus, ...result }) => { if (isMemberOfParentThread) { return { ...result }; } let notice, alertText; const userText = result.username; if (relationshipStatus === userRelationshipStatus.BLOCKED_BY_VIEWER) { notice = "you've blocked this user"; alertText = `Before you add ${userText} to this thread, ` + `you'll need to unblock them and send a friend request. ` + `You can do this from the Block List and Friend List in the More tab.`; } else if (relationshipStatus !== userRelationshipStatus.FRIEND) { notice = 'not friend'; alertText = `Before you add ${userText} to this thread, ` + `you'll need to send them a friend request. ` + `You can do this from the Friend List in the More tab.`; } else if (parentThreadInfo) { notice = 'not in parent thread'; } return { ...result, notice, alertText }; }, ); } export { getPotentialMemberItems }; diff --git a/lib/shared/thread-utils.js b/lib/shared/thread-utils.js index 44b0fea8e..f77108f9f 100644 --- a/lib/shared/thread-utils.js +++ b/lib/shared/thread-utils.js @@ -1,518 +1,517 @@ // @flow +import invariant from 'invariant'; +import _find from 'lodash/fp/find'; +import tinycolor from 'tinycolor2'; + +import { + permissionLookup, + getAllThreadPermissions, +} from '../permissions/thread-permissions'; +import { userRelationshipStatus } from '../types/relationship-types'; import { type RawThreadInfo, type ThreadInfo, type ThreadPermission, type MemberInfo, type ServerThreadInfo, type RelativeMemberInfo, type ThreadCurrentUserInfo, type RoleInfo, type ServerMemberInfo, type ThreadPermissionsInfo, threadTypes, threadPermissions, } from '../types/thread-types'; -import type { UserInfos } from '../types/user-types'; import { type UpdateInfo, updateTypes } from '../types/update-types'; -import { userRelationshipStatus } from '../types/relationship-types'; - -import tinycolor from 'tinycolor2'; -import _find from 'lodash/fp/find'; -import invariant from 'invariant'; - +import type { UserInfos } from '../types/user-types'; import { pluralize } from '../utils/text-utils'; -import { - permissionLookup, - getAllThreadPermissions, -} from '../permissions/thread-permissions'; function colorIsDark(color: string) { return tinycolor(`#${color}`).isDark(); } // Randomly distributed in RGB-space const hexNumerals = '0123456789abcdef'; function generateRandomColor() { let color = ''; for (let i = 0; i < 6; i++) { color += hexNumerals[Math.floor(Math.random() * 16)]; } return color; } function threadHasPermission( threadInfo: ?(ThreadInfo | RawThreadInfo), permission: ThreadPermission, ): boolean { invariant( !permissionsDisabledByBlock.has(permission) || threadInfo?.uiName, `${permission} can be disabled by a block, but threadHasPermission can't ` + 'check for a block on RawThreadInfo. Please pass in ThreadInfo instead!', ); if (!threadInfo || !threadInfo.currentUser.permissions[permission]) { return false; } return threadInfo.currentUser.permissions[permission].value; } function viewerIsMember(threadInfo: ?(ThreadInfo | RawThreadInfo)): boolean { return !!( threadInfo && threadInfo.currentUser.role !== null && threadInfo.currentUser.role !== undefined ); } function threadIsInHome(threadInfo: ?(ThreadInfo | RawThreadInfo)): boolean { return !!(threadInfo && threadInfo.currentUser.subscription.home); } // Can have messages function threadInChatList(threadInfo: ?(ThreadInfo | RawThreadInfo)): boolean { return ( viewerIsMember(threadInfo) && threadHasPermission(threadInfo, threadPermissions.VISIBLE) ); } function threadIsTopLevel(threadInfo: ?(ThreadInfo | RawThreadInfo)): boolean { return !!( threadInChatList(threadInfo) && threadInfo && threadInfo.type !== threadTypes.SIDEBAR ); } function threadInBackgroundChatList( threadInfo: ?(ThreadInfo | RawThreadInfo), ): boolean { return threadInChatList(threadInfo) && !threadIsInHome(threadInfo); } function threadInHomeChatList( threadInfo: ?(ThreadInfo | RawThreadInfo), ): boolean { return threadInChatList(threadInfo) && threadIsInHome(threadInfo); } // Can have Calendar entries, // does appear as a top-level entity in the thread list function threadInFilterList( threadInfo: ?(ThreadInfo | RawThreadInfo), ): boolean { return ( threadInChatList(threadInfo) && !!threadInfo && threadInfo.type !== threadTypes.SIDEBAR ); } function userIsMember( threadInfo: ?(ThreadInfo | RawThreadInfo), userID: string, ): boolean { if (!threadInfo) { return false; } return threadInfo.members.some( (member) => member.id === userID && member.role !== null && member.role !== undefined, ); } function threadActualMembers( memberInfos: $ReadOnlyArray, ): $ReadOnlyArray { return memberInfos .filter( (memberInfo) => memberInfo.role !== null && memberInfo.role !== undefined, ) .map((memberInfo) => memberInfo.id); } function threadIsGroupChat(threadInfo: ThreadInfo | RawThreadInfo) { return ( threadInfo.members.filter( (member) => member.role || member.permissions[threadPermissions.VOICED]?.value, ).length > 2 ); } function threadOrParentThreadIsGroupChat( threadInfo: RawThreadInfo | ThreadInfo, ) { return threadInfo.members.length > 2; } function threadIsPending(threadID: ?string) { return threadID?.startsWith('pending'); } function threadIsPersonalAndPending(threadInfo: ?(ThreadInfo | RawThreadInfo)) { return ( threadInfo?.type === threadTypes.PERSONAL && threadIsPending(threadInfo?.id) ); } type RawThreadInfoOptions = {| +includeVisibilityRules?: ?boolean, +filterMemberList?: ?boolean, |}; function rawThreadInfoFromServerThreadInfo( serverThreadInfo: ServerThreadInfo, viewerID: string, options?: RawThreadInfoOptions, ): ?RawThreadInfo { const includeVisibilityRules = options?.includeVisibilityRules; const filterMemberList = options?.filterMemberList; const members = []; let currentUser; for (const serverMember of serverThreadInfo.members) { if ( filterMemberList && serverMember.id !== viewerID && !serverMember.role && !memberHasAdminPowers(serverMember) ) { continue; } members.push({ id: serverMember.id, role: serverMember.role, permissions: serverMember.permissions, }); if (serverMember.id === viewerID) { currentUser = { role: serverMember.role, permissions: serverMember.permissions, subscription: serverMember.subscription, unread: serverMember.unread, }; } } let currentUserPermissions; if (currentUser) { currentUserPermissions = currentUser.permissions; } else { currentUserPermissions = getAllThreadPermissions(null, serverThreadInfo.id); currentUser = { role: null, permissions: currentUserPermissions, subscription: { home: false, pushNotifs: false, }, unread: null, }; } if (!permissionLookup(currentUserPermissions, threadPermissions.KNOW_OF)) { return null; } const rawThreadInfo = { id: serverThreadInfo.id, type: serverThreadInfo.type, name: serverThreadInfo.name, description: serverThreadInfo.description, color: serverThreadInfo.color, creationTime: serverThreadInfo.creationTime, parentThreadID: serverThreadInfo.parentThreadID, members, roles: serverThreadInfo.roles, currentUser, }; if (!includeVisibilityRules) { return rawThreadInfo; } return ({ ...rawThreadInfo, visibilityRules: rawThreadInfo.type, }: any); } function robotextName( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): string { const threadUsernames: string[] = threadInfo.members .filter( (threadMember) => threadMember.id !== viewerID && (threadMember.role || memberHasAdminPowers(threadMember)), ) .map( (threadMember) => userInfos[threadMember.id] && userInfos[threadMember.id].username, ) .filter(Boolean); if (threadUsernames.length === 0) { return 'just you'; } return pluralize(threadUsernames); } function threadUIName( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): string { if (threadInfo.name) { return threadInfo.name; } return robotextName(threadInfo, viewerID, userInfos); } function threadInfoFromRawThreadInfo( rawThreadInfo: RawThreadInfo, viewerID: ?string, userInfos: UserInfos, ): ThreadInfo { return { id: rawThreadInfo.id, type: rawThreadInfo.type, name: rawThreadInfo.name, uiName: threadUIName(rawThreadInfo, viewerID, userInfos), description: rawThreadInfo.description, color: rawThreadInfo.color, creationTime: rawThreadInfo.creationTime, parentThreadID: rawThreadInfo.parentThreadID, members: rawThreadInfo.members, roles: rawThreadInfo.roles, currentUser: getCurrentUser(rawThreadInfo, viewerID, userInfos), }; } function getCurrentUser( rawThreadInfo: RawThreadInfo, viewerID: ?string, userInfos: UserInfos, ): ThreadCurrentUserInfo { if (!threadFrozenDueToBlock(rawThreadInfo, viewerID, userInfos)) { return rawThreadInfo.currentUser; } return { ...rawThreadInfo.currentUser, permissions: { ...rawThreadInfo.currentUser.permissions, ...disabledPermissions, }, }; } function threadIsWithBlockedUserOnly( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, checkOnlyViewerBlock?: boolean, ): boolean { if ( threadOrParentThreadIsGroupChat(threadInfo) || threadOrParentThreadHasAdminRole(threadInfo) ) { return false; } const otherUserInfos = threadInfo.members .filter((threadMember) => threadMember.id !== viewerID) .map((threadMember) => userInfos[threadMember.id]); const otherUserRelationshipStatus = otherUserInfos[0]?.relationshipStatus; if (checkOnlyViewerBlock) { return ( otherUserRelationshipStatus === userRelationshipStatus.BLOCKED_BY_VIEWER ); } return ( otherUserRelationshipStatus === userRelationshipStatus.BLOCKED_BY_VIEWER || otherUserRelationshipStatus === userRelationshipStatus.BLOCKED_VIEWER || otherUserRelationshipStatus === userRelationshipStatus.BOTH_BLOCKED ); } function threadFrozenDueToBlock( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): boolean { return threadIsWithBlockedUserOnly(threadInfo, viewerID, userInfos); } function threadFrozenDueToViewerBlock( threadInfo: RawThreadInfo | ThreadInfo, viewerID: ?string, userInfos: UserInfos, ): boolean { return threadIsWithBlockedUserOnly(threadInfo, viewerID, userInfos, true); } function rawThreadInfoFromThreadInfo(threadInfo: ThreadInfo): RawThreadInfo { return { id: threadInfo.id, type: threadInfo.type, name: threadInfo.name, description: threadInfo.description, color: threadInfo.color, creationTime: threadInfo.creationTime, parentThreadID: threadInfo.parentThreadID, members: threadInfo.members, roles: threadInfo.roles, currentUser: threadInfo.currentUser, }; } const threadTypeDescriptions = { [threadTypes.CHAT_NESTED_OPEN]: 'Anybody in the parent thread can see an open child thread.', [threadTypes.CHAT_SECRET]: 'Only visible to its members and admins of ancestor threads.', }; function usersInThreadInfo(threadInfo: RawThreadInfo | ThreadInfo): string[] { const userIDs = new Set(); for (let member of threadInfo.members) { userIDs.add(member.id); } return [...userIDs]; } function memberIsAdmin( memberInfo: RelativeMemberInfo | MemberInfo, threadInfo: ThreadInfo | RawThreadInfo, ) { return memberInfo.role && roleIsAdminRole(threadInfo.roles[memberInfo.role]); } // Since we don't have access to all of the ancestor ThreadInfos, we approximate // "parent admin" as anybody with CHANGE_ROLE permissions. function memberHasAdminPowers( memberInfo: RelativeMemberInfo | MemberInfo | ServerMemberInfo, ): boolean { return !!memberInfo.permissions[threadPermissions.CHANGE_ROLE]?.value; } function roleIsAdminRole(roleInfo: ?RoleInfo) { return roleInfo && !roleInfo.isDefault && roleInfo.name === 'Admins'; } function threadHasAdminRole( threadInfo: ?(RawThreadInfo | ThreadInfo | ServerThreadInfo), ) { if (!threadInfo) { return false; } return _find({ name: 'Admins' })(threadInfo.roles); } function threadOrParentThreadHasAdminRole( threadInfo: RawThreadInfo | ThreadInfo, ) { return ( threadInfo.members.filter((member) => memberHasAdminPowers(member)).length > 0 ); } function identifyInvalidatedThreads( updateInfos: $ReadOnlyArray, ): Set { const invalidated = new Set(); for (const updateInfo of updateInfos) { if (updateInfo.type === updateTypes.DELETE_THREAD) { invalidated.add(updateInfo.threadID); } } return invalidated; } const permissionsDisabledByBlockArray = [ threadPermissions.VOICED, threadPermissions.EDIT_ENTRIES, threadPermissions.EDIT_THREAD, threadPermissions.CREATE_SUBTHREADS, threadPermissions.CREATE_SIDEBARS, threadPermissions.JOIN_THREAD, threadPermissions.EDIT_PERMISSIONS, threadPermissions.ADD_MEMBERS, threadPermissions.REMOVE_MEMBERS, ]; const permissionsDisabledByBlock: Set = new Set( permissionsDisabledByBlockArray, ); const disabledPermissions: ThreadPermissionsInfo = permissionsDisabledByBlockArray.reduce( (permissions: ThreadPermissionsInfo, permission: string) => ({ ...permissions, [permission]: { value: false, source: null }, }), {}, ); // Consider updating itemHeight in native/chat/chat-thread-list.react.js // if you change this const emptyItemText = `Background threads are just like normal threads, except they don't ` + `contribute to your unread count.\n\n` + `To move a thread over here, switch the “Background” option in its settings.`; const threadSearchText = ( threadInfo: RawThreadInfo | ThreadInfo, userInfos: UserInfos, ): string => { const searchTextArray = []; if (threadInfo.name) { searchTextArray.push(threadInfo.name); } if (threadInfo.description) { searchTextArray.push(threadInfo.description); } for (let member of threadInfo.members) { const userInfo = userInfos[member.id]; if (userInfo && userInfo.username) { searchTextArray.push(userInfo.username); } } return searchTextArray.join(' '); }; export { colorIsDark, generateRandomColor, threadHasPermission, viewerIsMember, threadInChatList, threadIsTopLevel, threadInBackgroundChatList, threadInHomeChatList, threadIsInHome, threadInFilterList, userIsMember, threadActualMembers, threadIsGroupChat, threadIsPending, threadIsPersonalAndPending, threadFrozenDueToBlock, threadFrozenDueToViewerBlock, rawThreadInfoFromServerThreadInfo, robotextName, threadInfoFromRawThreadInfo, rawThreadInfoFromThreadInfo, threadTypeDescriptions, usersInThreadInfo, memberIsAdmin, memberHasAdminPowers, roleIsAdminRole, threadHasAdminRole, identifyInvalidatedThreads, permissionsDisabledByBlock, emptyItemText, threadSearchText, }; diff --git a/lib/shared/unshim-utils.js b/lib/shared/unshim-utils.js index 4c67ef790..333cd84a0 100644 --- a/lib/shared/unshim-utils.js +++ b/lib/shared/unshim-utils.js @@ -1,83 +1,83 @@ // @flow +import invariant from 'invariant'; +import _mapValues from 'lodash/fp/mapValues'; + import { type MessageStore, type RawMessageInfo, type MessageType, messageTypes, } from '../types/message-types'; -import _mapValues from 'lodash/fp/mapValues'; -import invariant from 'invariant'; - // Four photos were uploaded before dimensions were calculated server-side, // and delivered to clients without dimensions in the MultimediaMessageInfo. const preDimensionUploads = { '156642': { width: 1440, height: 1080 }, '156649': { width: 720, height: 803 }, '156794': { width: 720, height: 803 }, '156877': { width: 574, height: 454 }, }; function unshimFunc( messageInfo: RawMessageInfo, unshimTypes: Set, ): RawMessageInfo { if (messageInfo.type !== messageTypes.UNSUPPORTED) { return messageInfo; } if (!unshimTypes.has(messageInfo.unsupportedMessageInfo.type)) { return messageInfo; } const unwrapped = messageInfo.unsupportedMessageInfo; if (unwrapped.type === messageTypes.IMAGES) { return { ...unwrapped, media: unwrapped.media.map((media) => { if (media.dimensions) { return media; } const dimensions = preDimensionUploads[media.id]; invariant( dimensions, 'only four photos were uploaded before dimensions were calculated, ' + `and ${media.id} was not one of them`, ); return { ...media, dimensions }; }), }; } if (unwrapped.type === messageTypes.MULTIMEDIA) { for (let { type } of unwrapped.media) { if (type !== 'photo' && type !== 'video') { return messageInfo; } } } return unwrapped; } function unshimMessageStore( messageStore: MessageStore, unshimTypes: $ReadOnlyArray, ): MessageStore { const set = new Set(unshimTypes); const messages = _mapValues((messageInfo: RawMessageInfo) => unshimFunc(messageInfo, set), )(messageStore.messages); return { ...messageStore, messages }; } const localUnshimTypes = new Set([ messageTypes.IMAGES, messageTypes.MULTIMEDIA, ]); function unshimMessageInfos( messageInfos: $ReadOnlyArray, ): RawMessageInfo[] { return messageInfos.map((messageInfo: RawMessageInfo) => unshimFunc(messageInfo, localUnshimTypes), ); } export { unshimMessageStore, unshimMessageInfos }; diff --git a/lib/shared/update-utils.js b/lib/shared/update-utils.js index b14aafeba..29e4eaede 100644 --- a/lib/shared/update-utils.js +++ b/lib/shared/update-utils.js @@ -1,145 +1,145 @@ // @flow +import invariant from 'invariant'; +import _maxBy from 'lodash/fp/maxBy'; + import { type UpdateInfo, type UpdateData, type RawUpdateInfo, updateTypes, } from '../types/update-types'; -import _maxBy from 'lodash/fp/maxBy'; -import invariant from 'invariant'; - function mostRecentUpdateTimestamp( updateInfos: UpdateInfo[], previousTimestamp: number, ): number { if (updateInfos.length === 0) { return previousTimestamp; } return _maxBy('time')(updateInfos).time; } function keyForUpdateData(updateData: UpdateData): ?string { if ( updateData.type === updateTypes.UPDATE_THREAD || updateData.type === updateTypes.UPDATE_THREAD_READ_STATUS || updateData.type === updateTypes.DELETE_THREAD || updateData.type === updateTypes.JOIN_THREAD ) { return updateData.threadID; } else if (updateData.type === updateTypes.UPDATE_ENTRY) { return updateData.entryID; } else if (updateData.type === updateTypes.UPDATE_CURRENT_USER) { return updateData.userID; } else if (updateData.type === updateTypes.DELETE_ACCOUNT) { return updateData.deletedUserID; } else if (updateData.type === updateTypes.UPDATE_USER) { return updateData.updatedUserID; } return null; } function keyForUpdateInfo(updateInfo: UpdateInfo): ?string { if ( updateInfo.type === updateTypes.UPDATE_THREAD || updateInfo.type === updateTypes.JOIN_THREAD ) { return updateInfo.threadInfo.id; } else if ( updateInfo.type === updateTypes.UPDATE_THREAD_READ_STATUS || updateInfo.type === updateTypes.DELETE_THREAD ) { return updateInfo.threadID; } else if (updateInfo.type === updateTypes.UPDATE_ENTRY) { const { id } = updateInfo.entryInfo; invariant(id, 'should be set'); return id; } else if (updateInfo.type === updateTypes.UPDATE_CURRENT_USER) { return updateInfo.currentUserInfo.id; } else if (updateInfo.type === updateTypes.DELETE_ACCOUNT) { return updateInfo.deletedUserID; } else if (updateInfo.type === updateTypes.UPDATE_USER) { return updateInfo.updatedUserID; } return null; } function rawUpdateInfoFromUpdateData( updateData: UpdateData, id: string, ): RawUpdateInfo { if (updateData.type === updateTypes.DELETE_ACCOUNT) { return { type: updateTypes.DELETE_ACCOUNT, id, time: updateData.time, deletedUserID: updateData.deletedUserID, }; } else if (updateData.type === updateTypes.UPDATE_THREAD) { return { type: updateTypes.UPDATE_THREAD, id, time: updateData.time, threadID: updateData.threadID, }; } else if (updateData.type === updateTypes.UPDATE_THREAD_READ_STATUS) { return { type: updateTypes.UPDATE_THREAD_READ_STATUS, id, time: updateData.time, threadID: updateData.threadID, unread: updateData.unread, }; } else if (updateData.type === updateTypes.DELETE_THREAD) { return { type: updateTypes.DELETE_THREAD, id, time: updateData.time, threadID: updateData.threadID, }; } else if (updateData.type === updateTypes.JOIN_THREAD) { return { type: updateTypes.JOIN_THREAD, id, time: updateData.time, threadID: updateData.threadID, }; } else if (updateData.type === updateTypes.BAD_DEVICE_TOKEN) { return { type: updateTypes.BAD_DEVICE_TOKEN, id, time: updateData.time, deviceToken: updateData.deviceToken, }; } else if (updateData.type === updateTypes.UPDATE_ENTRY) { return { type: updateTypes.UPDATE_ENTRY, id, time: updateData.time, entryID: updateData.entryID, }; } else if (updateData.type === updateTypes.UPDATE_CURRENT_USER) { return { type: updateTypes.UPDATE_CURRENT_USER, id, time: updateData.time, }; } else if (updateData.type === updateTypes.UPDATE_USER) { return { type: updateTypes.UPDATE_USER, id, time: updateData.time, updatedUserID: updateData.updatedUserID, }; } else { invariant(false, `unrecognized updateType ${updateData.type}`); } } export { mostRecentUpdateTimestamp, keyForUpdateData, keyForUpdateInfo, rawUpdateInfoFromUpdateData, }; diff --git a/lib/shared/user-utils.js b/lib/shared/user-utils.js index 7cfbecb79..c97f922d9 100644 --- a/lib/shared/user-utils.js +++ b/lib/shared/user-utils.js @@ -1,32 +1,31 @@ // @flow -import type { RelativeUserInfo } from '../types/user-types'; -import type { RelativeMemberInfo } from '../types/thread-types'; - import ashoat from '../facts/ashoat'; import bots from '../facts/bots'; +import type { RelativeMemberInfo } from '../types/thread-types'; +import type { RelativeUserInfo } from '../types/user-types'; function stringForUser(user: RelativeUserInfo | RelativeMemberInfo): string { if (user.isViewer) { return 'you'; } else if (user.username) { return user.username; } else { return 'anonymous'; } } function isStaff(userID: string) { if (userID === ashoat.id) { return true; } for (let key in bots) { const bot = bots[key]; if (userID === bot.userID) { return true; } } return false; } export { stringForUser, isStaff }; diff --git a/lib/socket/activity-handler.react.js b/lib/socket/activity-handler.react.js index f533ac348..b32eb3174 100644 --- a/lib/socket/activity-handler.react.js +++ b/lib/socket/activity-handler.react.js @@ -1,124 +1,123 @@ // @flow -import { queueActivityUpdatesActionType } from '../types/activity-types'; - import * as React from 'react'; import { useDispatch } from 'react-redux'; import { updateActivityActionTypes, updateActivity, } from '../actions/activity-actions'; +import { queueActivityUpdatesActionType } from '../types/activity-types'; import { useServerCall, useDispatchActionPromise } from '../utils/action-utils'; import { useSelector } from '../utils/redux-utils'; type Props = {| activeThread: ?string, frozen: boolean, |}; function ActivityHandler(props: Props) { const { activeThread, frozen } = props; const prevActiveThreadRef = React.useRef(); React.useEffect(() => { prevActiveThreadRef.current = activeThread; }, [activeThread]); const prevActiveThread = prevActiveThreadRef.current; const connection = useSelector((state) => state.connection); const connectionStatus = connection.status; const prevConnectionStatusRef = React.useRef(); React.useEffect(() => { prevConnectionStatusRef.current = connectionStatus; }, [connectionStatus]); const prevConnectionStatus = prevConnectionStatusRef.current; const activeThreadLatestMessage = useSelector((state) => { if (!activeThread) { return undefined; } return state.messageStore.threads[activeThread]?.messageIDs.find( (id) => !id.startsWith('local'), ); }); const prevActiveThreadLatestMessageRef = React.useRef(); React.useEffect(() => { prevActiveThreadLatestMessageRef.current = activeThreadLatestMessage; }, [activeThreadLatestMessage]); const prevActiveThreadLatestMessage = prevActiveThreadLatestMessageRef.current; const canSend = connectionStatus === 'connected' && !frozen; const prevCanSendRef = React.useRef(); React.useEffect(() => { prevCanSendRef.current = canSend; }, [canSend]); const prevCanSend = prevCanSendRef.current; const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const callUpdateActivity = useServerCall(updateActivity); React.useEffect(() => { const activityUpdates = []; if (activeThread !== prevActiveThread) { if (prevActiveThread) { activityUpdates.push({ focus: false, threadID: prevActiveThread, latestMessage: prevActiveThreadLatestMessage, }); } if (activeThread) { activityUpdates.push({ focus: true, threadID: activeThread, latestMessage: activeThreadLatestMessage, }); } } if ( !frozen && connectionStatus !== 'connected' && prevConnectionStatus === 'connected' && activeThread ) { // When the server closes a socket it also deletes any activity rows // associated with that socket's session. If that activity is still // ongoing, we should make sure that we clarify that with the server once // we reconnect. activityUpdates.push({ focus: true, threadID: activeThread, }); } if (activityUpdates.length > 0) { dispatch({ type: queueActivityUpdatesActionType, payload: { activityUpdates }, }); } if (!canSend) { return; } if (!prevCanSend) { activityUpdates.unshift(...connection.queuedActivityUpdates); } if (activityUpdates.length === 0) { return; } dispatchActionPromise( updateActivityActionTypes, callUpdateActivity(activityUpdates), ); }); return null; } export default ActivityHandler; diff --git a/lib/socket/api-request-handler.react.js b/lib/socket/api-request-handler.react.js index baa819ca2..7c8786553 100644 --- a/lib/socket/api-request-handler.react.js +++ b/lib/socket/api-request-handler.react.js @@ -1,102 +1,102 @@ // @flow +import invariant from 'invariant'; +import PropTypes from 'prop-types'; +import * as React from 'react'; + +import type { APIRequest } from '../types/endpoints'; +import type { BaseAppState } from '../types/redux-types'; import { clientSocketMessageTypes, serverSocketMessageTypes, type ClientSocketMessageWithoutID, type ConnectionInfo, connectionInfoPropType, } from '../types/socket-types'; -import type { APIRequest } from '../types/endpoints'; -import type { BaseAppState } from '../types/redux-types'; - -import * as React from 'react'; -import PropTypes from 'prop-types'; -import invariant from 'invariant'; - -import { connect } from '../utils/redux-utils'; import { registerActiveSocket } from '../utils/action-utils'; +import { connect } from '../utils/redux-utils'; + import { InflightRequests, SocketOffline } from './inflight-requests'; type Props = {| inflightRequests: ?InflightRequests, sendMessage: (message: ClientSocketMessageWithoutID) => number, // Redux state connection: ConnectionInfo, |}; class APIRequestHandler extends React.PureComponent { static propTypes = { inflightRequests: PropTypes.object, sendMessage: PropTypes.func.isRequired, connection: connectionInfoPropType.isRequired, }; static isConnected(props: Props, request?: APIRequest) { const { inflightRequests, connection } = props; if (!inflightRequests) { return false; } // This is a hack. We actually have a race condition between // ActivityHandler and Socket. Both of them respond to a backgrounding, but // we want ActivityHandler to go first. Once it sends its message, Socket // will wait for the response before shutting down. But if Socket starts // shutting down first, we'll have a problem. Note that this approach only // stops the race in fetchResponse below, and not in action-utils (which // happens earlier via the registerActiveSocket call below), but empircally // that hasn't been an issue. // The reason I didn't rewrite this to happen in a single component is // because I want to maintain separation of concerns. Upcoming React Hooks // will be a great way to rewrite them to be related but still separated. return ( connection.status === 'connected' || (request && request.endpoint === 'update_activity') ); } get registeredResponseFetcher() { return APIRequestHandler.isConnected(this.props) ? this.fetchResponse : null; } componentDidMount() { registerActiveSocket(this.registeredResponseFetcher); } componentWillUnmount() { registerActiveSocket(null); } componentDidUpdate(prevProps: Props) { const isConnected = APIRequestHandler.isConnected(this.props); const wasConnected = APIRequestHandler.isConnected(prevProps); if (isConnected !== wasConnected) { registerActiveSocket(this.registeredResponseFetcher); } } render() { return null; } fetchResponse = async (request: APIRequest): Promise => { if (!APIRequestHandler.isConnected(this.props, request)) { throw new SocketOffline('socket_offline'); } const { inflightRequests } = this.props; invariant(inflightRequests, 'inflightRequests falsey inside fetchResponse'); const messageID = this.props.sendMessage({ type: clientSocketMessageTypes.API_REQUEST, payload: request, }); const response = await inflightRequests.fetchResponse( messageID, serverSocketMessageTypes.API_RESPONSE, ); return response.payload; }; } export default connect((state: BaseAppState<*>) => ({ connection: state.connection, }))(APIRequestHandler); diff --git a/lib/socket/calendar-query-handler.react.js b/lib/socket/calendar-query-handler.react.js index 3e35fc4e3..ba716e43a 100644 --- a/lib/socket/calendar-query-handler.react.js +++ b/lib/socket/calendar-query-handler.react.js @@ -1,141 +1,140 @@ // @flow +import _isEqual from 'lodash/fp/isEqual'; +import PropTypes from 'prop-types'; +import * as React from 'react'; + import { - type ConnectionInfo, - connectionInfoPropType, -} from '../types/socket-types'; -import type { BaseAppState } from '../types/redux-types'; + updateCalendarQueryActionTypes, + updateCalendarQuery, +} from '../actions/entry-actions'; +import { timeUntilCalendarRangeExpiration } from '../selectors/nav-selectors'; import type { CalendarQuery, CalendarQueryUpdateResult, CalendarQueryUpdateStartingPayload, } from '../types/entry-types'; +import type { BaseAppState } from '../types/redux-types'; +import { + type ConnectionInfo, + connectionInfoPropType, +} from '../types/socket-types'; import type { DispatchActionPromise } from '../utils/action-utils'; - -import * as React from 'react'; -import PropTypes from 'prop-types'; -import _isEqual from 'lodash/fp/isEqual'; - import { connect } from '../utils/redux-utils'; -import { - updateCalendarQueryActionTypes, - updateCalendarQuery, -} from '../actions/entry-actions'; -import { timeUntilCalendarRangeExpiration } from '../selectors/nav-selectors'; type Props = {| currentCalendarQuery: () => CalendarQuery, frozen: boolean, // Redux state connection: ConnectionInfo, lastUserInteractionCalendar: number, foreground: boolean, // Redux dispatch functions dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs updateCalendarQuery: ( calendarQuery: CalendarQuery, reduxAlreadyUpdated?: boolean, ) => Promise, |}; class CalendarQueryHandler extends React.PureComponent { static propTypes = { currentCalendarQuery: PropTypes.func.isRequired, frozen: PropTypes.bool.isRequired, connection: connectionInfoPropType.isRequired, lastUserInteractionCalendar: PropTypes.number.isRequired, foreground: PropTypes.bool.isRequired, dispatchActionPromise: PropTypes.func.isRequired, updateCalendarQuery: PropTypes.func.isRequired, }; serverCalendarQuery: CalendarQuery; expirationTimeoutID: ?TimeoutID; constructor(props: Props) { super(props); this.serverCalendarQuery = this.props.connection.actualizedCalendarQuery; } componentDidMount() { if (this.props.connection.status === 'connected') { this.possiblyUpdateCalendarQuery(); } } componentDidUpdate(prevProps: Props) { const { actualizedCalendarQuery } = this.props.connection; if (this.props.connection.status !== 'connected') { if (!_isEqual(this.serverCalendarQuery)(actualizedCalendarQuery)) { this.serverCalendarQuery = actualizedCalendarQuery; } return; } if ( !_isEqual(this.serverCalendarQuery)(actualizedCalendarQuery) && _isEqual(this.props.currentCalendarQuery())(actualizedCalendarQuery) ) { this.serverCalendarQuery = actualizedCalendarQuery; } const shouldUpdate = (this.isExpired || prevProps.connection.status !== 'connected' || this.props.currentCalendarQuery !== prevProps.currentCalendarQuery) && this.shouldUpdateCalendarQuery; if (shouldUpdate) { this.updateCalendarQuery(); } } render() { return null; } get isExpired() { const timeUntilExpiration = timeUntilCalendarRangeExpiration( this.props.lastUserInteractionCalendar, ); return ( timeUntilExpiration !== null && timeUntilExpiration !== undefined && timeUntilExpiration <= 0 ); } get shouldUpdateCalendarQuery() { if (this.props.connection.status !== 'connected' || this.props.frozen) { return false; } const calendarQuery = this.props.currentCalendarQuery(); return !_isEqual(calendarQuery)(this.serverCalendarQuery); } updateCalendarQuery() { const calendarQuery = this.props.currentCalendarQuery(); this.serverCalendarQuery = calendarQuery; this.props.dispatchActionPromise( updateCalendarQueryActionTypes, this.props.updateCalendarQuery(calendarQuery, true), undefined, ({ calendarQuery }: CalendarQueryUpdateStartingPayload), ); } possiblyUpdateCalendarQuery = () => { if (this.shouldUpdateCalendarQuery) { this.updateCalendarQuery(); } }; } export default connect( (state: BaseAppState<*>) => ({ connection: state.connection, lastUserInteractionCalendar: state.entryStore.lastUserInteractionCalendar, // We include this so that componentDidUpdate will be called on foreground foreground: state.foreground, }), { updateCalendarQuery }, )(CalendarQueryHandler); diff --git a/lib/socket/inflight-requests.js b/lib/socket/inflight-requests.js index 4feb0167a..a6c891887 100644 --- a/lib/socket/inflight-requests.js +++ b/lib/socket/inflight-requests.js @@ -1,251 +1,250 @@ // @flow +import invariant from 'invariant'; + +import { + clientRequestVisualTimeout, + clientRequestSocketTimeout, +} from '../shared/timeouts'; import { type ServerSocketMessage, type StateSyncServerSocketMessage, type RequestsServerSocketMessage, type ActivityUpdateResponseServerSocketMessage, type PongServerSocketMessage, type APIResponseServerSocketMessage, type ServerSocketMessageType, serverSocketMessageTypes, } from '../types/socket-types'; - -import invariant from 'invariant'; - import { ServerError, ExtendableError } from '../utils/errors'; -import { - clientRequestVisualTimeout, - clientRequestSocketTimeout, -} from '../shared/timeouts'; import sleep from '../utils/sleep'; type ValidResponseMessageMap = { a: StateSyncServerSocketMessage, b: RequestsServerSocketMessage, c: ActivityUpdateResponseServerSocketMessage, d: PongServerSocketMessage, e: APIResponseServerSocketMessage, }; type BaseInflightRequest = {| expectedResponseType: $PropertyType, resolve: (response: Response) => void, reject: (error: Error) => void, messageID: number, |}; type InflightRequestMap = $ObjMap< ValidResponseMessageMap, (T) => BaseInflightRequest<$Exact>, >; type ValidResponseMessage = $Values; type InflightRequest = $Values; const remainingTimeAfterVisualTimeout = clientRequestSocketTimeout - clientRequestVisualTimeout; class SocketOffline extends ExtendableError {} class SocketTimeout extends ExtendableError { expectedResponseType: ServerSocketMessageType; constructor(expectedType: ServerSocketMessageType) { super(`socket timed out waiting for response type ${expectedType}`); this.expectedResponseType = expectedType; } } type Callbacks = {| timeout: () => void, setLateResponse: (messageID: number, isLate: boolean) => void, |}; class InflightRequests { data: InflightRequest[] = []; timeoutCallback: () => void; setLateResponse: (messageID: number, isLate: boolean) => void; constructor(callbacks: Callbacks) { this.timeoutCallback = callbacks.timeout; this.setLateResponse = callbacks.setLateResponse; } async fetchResponse( messageID: number, expectedType: $PropertyType, ): Promise { let inflightRequest: ?InflightRequest; const responsePromise = new Promise((resolve, reject) => { // Flow makes us do these unnecessary runtime checks... if (expectedType === serverSocketMessageTypes.STATE_SYNC) { inflightRequest = { expectedResponseType: serverSocketMessageTypes.STATE_SYNC, resolve, reject, messageID, }; } else if (expectedType === serverSocketMessageTypes.REQUESTS) { inflightRequest = { expectedResponseType: serverSocketMessageTypes.REQUESTS, resolve, reject, messageID, }; } else if ( expectedType === serverSocketMessageTypes.ACTIVITY_UPDATE_RESPONSE ) { inflightRequest = { expectedResponseType: serverSocketMessageTypes.ACTIVITY_UPDATE_RESPONSE, resolve, reject, messageID, }; } else if (expectedType === serverSocketMessageTypes.PONG) { inflightRequest = { expectedResponseType: serverSocketMessageTypes.PONG, resolve, reject, messageID, }; } else if (expectedType === serverSocketMessageTypes.API_RESPONSE) { inflightRequest = { expectedResponseType: serverSocketMessageTypes.API_RESPONSE, resolve, reject, messageID, }; } }); invariant( inflightRequest, `${expectedType} is an invalid server response type`, ); this.data.push(inflightRequest); // We create this object so we can pass it by reference to the timeout // function below. That function will avoid setting this request as late if // the response has already arrived. const requestResult = { concluded: false, lateResponse: false }; try { const response = await Promise.race([ responsePromise, this.timeout(messageID, expectedType, requestResult), ]); requestResult.concluded = true; if (requestResult.lateResponse) { this.setLateResponse(messageID, false); } this.clearRequest(inflightRequest); return response; } catch (e) { requestResult.concluded = true; this.clearRequest(inflightRequest); if (e instanceof SocketTimeout) { this.rejectAll(new Error('socket closed due to timeout')); this.timeoutCallback(); } else if (requestResult.lateResponse) { this.setLateResponse(messageID, false); } throw e; } } async timeout( messageID: number, expectedType: ServerSocketMessageType, requestResult: {| concluded: boolean, lateResponse: boolean |}, ) { await sleep(clientRequestVisualTimeout); if (requestResult.concluded) { // We're just doing this to bail out. If requestResult.concluded we can // conclude that responsePromise already won the race. Returning here // gives Flow errors since Flow is worried response will be undefined. throw new Error(); } requestResult.lateResponse = true; this.setLateResponse(messageID, true); await sleep(remainingTimeAfterVisualTimeout); throw new SocketTimeout(expectedType); } clearRequest(requestToClear: InflightRequest) { this.data = this.data.filter((request) => request !== requestToClear); } resolveRequestsForMessage(message: ServerSocketMessage) { for (let inflightRequest of this.data) { if ( message.responseTo === null || message.responseTo === undefined || inflightRequest.messageID !== message.responseTo ) { continue; } if (message.type === serverSocketMessageTypes.ERROR) { const error = message.payload ? new ServerError(message.message, message.payload) : new ServerError(message.message); inflightRequest.reject(error); } else if (message.type === serverSocketMessageTypes.AUTH_ERROR) { inflightRequest.reject(new SocketOffline('auth_error')); } else if ( message.type === serverSocketMessageTypes.STATE_SYNC && inflightRequest.expectedResponseType === serverSocketMessageTypes.STATE_SYNC ) { inflightRequest.resolve(message); } else if ( message.type === serverSocketMessageTypes.REQUESTS && inflightRequest.expectedResponseType === serverSocketMessageTypes.REQUESTS ) { inflightRequest.resolve(message); } else if ( message.type === serverSocketMessageTypes.ACTIVITY_UPDATE_RESPONSE && inflightRequest.expectedResponseType === serverSocketMessageTypes.ACTIVITY_UPDATE_RESPONSE ) { inflightRequest.resolve(message); } else if ( message.type === serverSocketMessageTypes.PONG && inflightRequest.expectedResponseType === serverSocketMessageTypes.PONG ) { inflightRequest.resolve(message); } else if ( message.type === serverSocketMessageTypes.API_RESPONSE && inflightRequest.expectedResponseType === serverSocketMessageTypes.API_RESPONSE ) { inflightRequest.resolve(message); } } } rejectAll(error: Error) { const { data } = this; // Though the promise rejections below should call clearRequest when they're // caught in fetchResponse, that doesn't happen synchronously. Socket won't // close unless all requests are resolved, so we clear this.data immediately this.data = []; for (let inflightRequest of data) { const { reject } = inflightRequest; reject(error); } } allRequestsResolvedExcept(excludeMessageID: ?number) { for (let inflightRequest of this.data) { const { expectedResponseType } = inflightRequest; if ( expectedResponseType !== serverSocketMessageTypes.PONG && (excludeMessageID === null || excludeMessageID === undefined || excludeMessageID !== inflightRequest.messageID) ) { return false; } } return true; } } export { SocketOffline, SocketTimeout, InflightRequests }; diff --git a/lib/socket/message-handler.react.js b/lib/socket/message-handler.react.js index 0767588dc..60858ada8 100644 --- a/lib/socket/message-handler.react.js +++ b/lib/socket/message-handler.react.js @@ -1,52 +1,51 @@ // @flow +import PropTypes from 'prop-types'; +import * as React from 'react'; + +import { processMessagesActionType } from '../actions/message-actions'; import { type ServerSocketMessage, serverSocketMessageTypes, type SocketListener, } from '../types/socket-types'; -import { processMessagesActionType } from '../actions/message-actions'; import type { DispatchActionPayload } from '../utils/action-utils'; - -import * as React from 'react'; -import PropTypes from 'prop-types'; - import { connect } from '../utils/redux-utils'; type Props = {| addListener: (listener: SocketListener) => void, removeListener: (listener: SocketListener) => void, // Redux dispatch functions dispatchActionPayload: DispatchActionPayload, |}; class MessageHandler extends React.PureComponent { static propTypes = { addListener: PropTypes.func.isRequired, removeListener: PropTypes.func.isRequired, dispatchActionPayload: PropTypes.func.isRequired, }; componentDidMount() { this.props.addListener(this.onMessage); } componentWillUnmount() { this.props.removeListener(this.onMessage); } render() { return null; } onMessage = (message: ServerSocketMessage) => { if (message.type !== serverSocketMessageTypes.MESSAGES) { return; } this.props.dispatchActionPayload( processMessagesActionType, message.payload, ); }; } export default connect(null, null, true)(MessageHandler); diff --git a/lib/socket/report-handler.react.js b/lib/socket/report-handler.react.js index a33c93bbb..68549dbf8 100644 --- a/lib/socket/report-handler.react.js +++ b/lib/socket/report-handler.react.js @@ -1,91 +1,90 @@ // @flow +import PropTypes from 'prop-types'; +import * as React from 'react'; + +import { sendReportsActionTypes, sendReports } from '../actions/report-actions'; +import { queuedReports } from '../selectors/socket-selectors'; +import type { AppState } from '../types/redux-types'; import { type ClientReportCreationRequest, type ClearDeliveredReportsPayload, queuedClientReportCreationRequestPropType, } from '../types/report-types'; import type { DispatchActionPromise } from '../utils/action-utils'; -import type { AppState } from '../types/redux-types'; - -import * as React from 'react'; -import PropTypes from 'prop-types'; - import { connect } from '../utils/redux-utils'; -import { sendReportsActionTypes, sendReports } from '../actions/report-actions'; -import { queuedReports } from '../selectors/socket-selectors'; type Props = {| canSendReports: boolean, // Redux state queuedReports: $ReadOnlyArray, // Redux dispatch functions dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs sendReports: ( reports: $ReadOnlyArray, ) => Promise, |}; class ReportHandler extends React.PureComponent { static propTypes = { canSendReports: PropTypes.bool.isRequired, queuedReports: PropTypes.arrayOf(queuedClientReportCreationRequestPropType) .isRequired, dispatchActionPromise: PropTypes.func.isRequired, sendReports: PropTypes.func.isRequired, }; componentDidMount() { if (this.props.canSendReports) { this.dispatchSendReports(this.props.queuedReports); } } componentDidUpdate(prevProps: Props) { if (!this.props.canSendReports) { return; } const couldSend = prevProps.canSendReports; const curReports = this.props.queuedReports; if (!couldSend) { this.dispatchSendReports(curReports); return; } const prevReports = prevProps.queuedReports; if (curReports !== prevReports) { const prevResponses = new Set(prevReports); const newResponses = curReports.filter( (response) => !prevResponses.has(response), ); this.dispatchSendReports(newResponses); } } render() { return null; } dispatchSendReports(reports: $ReadOnlyArray) { if (reports.length === 0) { return; } this.props.dispatchActionPromise( sendReportsActionTypes, this.sendReports(reports), ); } async sendReports(reports: $ReadOnlyArray) { await this.props.sendReports(reports); return ({ reports }: ClearDeliveredReportsPayload); } } export default connect( (state: AppState) => ({ queuedReports: queuedReports(state), }), { sendReports }, )(ReportHandler); diff --git a/lib/socket/request-response-handler.react.js b/lib/socket/request-response-handler.react.js index 0f8e84e1a..ad323a452 100644 --- a/lib/socket/request-response-handler.react.js +++ b/lib/socket/request-response-handler.react.js @@ -1,151 +1,151 @@ // @flow +import invariant from 'invariant'; +import PropTypes from 'prop-types'; +import * as React from 'react'; + +import type { CalendarQuery } from '../types/entry-types'; +import type { AppState } from '../types/redux-types'; +import { + processServerRequestsActionType, + type ClientClientResponse, + type ServerRequest, +} from '../types/request-types'; import { type RequestsServerSocketMessage, type ServerSocketMessage, clientSocketMessageTypes, serverSocketMessageTypes, type ClientSocketMessageWithoutID, type SocketListener, type ConnectionInfo, connectionInfoPropType, } from '../types/socket-types'; -import { - processServerRequestsActionType, - type ClientClientResponse, - type ServerRequest, -} from '../types/request-types'; -import type { CalendarQuery } from '../types/entry-types'; import type { DispatchActionPayload } from '../utils/action-utils'; -import type { AppState } from '../types/redux-types'; - -import * as React from 'react'; -import PropTypes from 'prop-types'; -import invariant from 'invariant'; - +import { ServerError } from '../utils/errors'; import { connect } from '../utils/redux-utils'; + import { InflightRequests, SocketTimeout } from './inflight-requests'; -import { ServerError } from '../utils/errors'; type Props = {| inflightRequests: ?InflightRequests, sendMessage: (message: ClientSocketMessageWithoutID) => number, addListener: (listener: SocketListener) => void, removeListener: (listener: SocketListener) => void, getClientResponses: ( activeServerRequests: $ReadOnlyArray, ) => $ReadOnlyArray, currentCalendarQuery: () => CalendarQuery, // Redux state connection: ConnectionInfo, // Redux dispatch functions dispatchActionPayload: DispatchActionPayload, |}; class RequestResponseHandler extends React.PureComponent { static propTypes = { inflightRequests: PropTypes.object, sendMessage: PropTypes.func.isRequired, addListener: PropTypes.func.isRequired, removeListener: PropTypes.func.isRequired, getClientResponses: PropTypes.func.isRequired, currentCalendarQuery: PropTypes.func.isRequired, connection: connectionInfoPropType.isRequired, dispatchActionPayload: PropTypes.func.isRequired, }; componentDidMount() { this.props.addListener(this.onMessage); } componentWillUnmount() { this.props.removeListener(this.onMessage); } render() { return null; } onMessage = (message: ServerSocketMessage) => { if (message.type !== serverSocketMessageTypes.REQUESTS) { return; } const { serverRequests } = message.payload; if (serverRequests.length === 0) { return; } const calendarQuery = this.props.currentCalendarQuery(); this.props.dispatchActionPayload(processServerRequestsActionType, { serverRequests, calendarQuery, }); if (this.props.inflightRequests) { const clientResponses = this.props.getClientResponses(serverRequests); this.sendAndHandleClientResponsesToServerRequests(clientResponses); } }; sendClientResponses( clientResponses: $ReadOnlyArray, ): Promise { const { inflightRequests } = this.props; invariant( inflightRequests, 'inflightRequests falsey inside sendClientResponses', ); const messageID = this.props.sendMessage({ type: clientSocketMessageTypes.RESPONSES, payload: { clientResponses }, }); return inflightRequests.fetchResponse( messageID, serverSocketMessageTypes.REQUESTS, ); } sendAndHandleClientResponsesToServerRequests( clientResponses: $ReadOnlyArray, ) { if (clientResponses.length === 0) { return; } const promise = this.sendClientResponses(clientResponses); this.handleClientResponsesToServerRequests(promise, clientResponses); } async handleClientResponsesToServerRequests( promise: Promise, clientResponses: $ReadOnlyArray, retriesLeft: number = 1, ): Promise { try { await promise; } catch (e) { console.log(e); if ( !(e instanceof SocketTimeout) && (!(e instanceof ServerError) || e.message === 'unknown_error') && retriesLeft > 0 && this.props.connection.status === 'connected' && this.props.inflightRequests ) { // We'll only retry if the connection is healthy and the error is either // an unknown_error ServerError or something is neither a ServerError // nor a SocketTimeout. const newPromise = this.sendClientResponses(clientResponses); await this.handleClientResponsesToServerRequests( newPromise, clientResponses, retriesLeft - 1, ); } } } } export default connect( (state: AppState) => ({ connection: state.connection, }), null, true, )(RequestResponseHandler); diff --git a/lib/socket/socket.react.js b/lib/socket/socket.react.js index e908c419e..3744707fa 100644 --- a/lib/socket/socket.react.js +++ b/lib/socket/socket.react.js @@ -1,751 +1,751 @@ // @flow +import invariant from 'invariant'; +import _throttle from 'lodash/throttle'; +import PropTypes from 'prop-types'; +import * as React from 'react'; + +import { updateActivityActionTypes } from '../actions/activity-actions'; +import { + socketAuthErrorResolutionAttempt, + logOutActionTypes, +} from '../actions/user-actions'; +import { unsupervisedBackgroundActionType } from '../reducers/foreground-reducer'; +import { + pingFrequency, + serverRequestSocketTimeout, + clientRequestVisualTimeout, + clientRequestSocketTimeout, +} from '../shared/timeouts'; +import type { LogOutResult } from '../types/account-types'; +import type { CalendarQuery } from '../types/entry-types'; +import type { Dispatch } from '../types/redux-types'; import { serverRequestTypes, type ClientClientResponse, type ServerRequest, } from '../types/request-types'; import { type SessionState, type SessionIdentification, sessionIdentificationPropType, type PreRequestUserState, preRequestUserStatePropType, } from '../types/session-types'; import { clientSocketMessageTypes, type ClientClientSocketMessage, serverSocketMessageTypes, type ServerSocketMessage, stateSyncPayloadTypes, fullStateSyncActionType, incrementalStateSyncActionType, updateConnectionStatusActionType, connectionInfoPropType, type ConnectionInfo, type ClientInitialClientSocketMessage, type ClientResponsesClientSocketMessage, type PingClientSocketMessage, type AckUpdatesClientSocketMessage, type APIRequestClientSocketMessage, type ClientSocketMessageWithoutID, type SocketListener, type ConnectionStatus, setLateResponseActionType, } from '../types/socket-types'; -import type { Dispatch } from '../types/redux-types'; +import { actionLogger } from '../utils/action-logger'; import type { DispatchActionPromise } from '../utils/action-utils'; -import type { LogOutResult } from '../types/account-types'; -import type { CalendarQuery } from '../types/entry-types'; - -import * as React from 'react'; -import PropTypes from 'prop-types'; -import invariant from 'invariant'; -import _throttle from 'lodash/throttle'; - -import { getConfig } from '../utils/config'; import { setNewSessionActionType, fetchNewCookieFromNativeCredentials, } from '../utils/action-utils'; -import { - socketAuthErrorResolutionAttempt, - logOutActionTypes, -} from '../actions/user-actions'; +import { getConfig } from '../utils/config'; import { ServerError } from '../utils/errors'; -import { - pingFrequency, - serverRequestSocketTimeout, - clientRequestVisualTimeout, - clientRequestSocketTimeout, -} from '../shared/timeouts'; import { promiseAll } from '../utils/promises'; +import sleep from '../utils/sleep'; + +import ActivityHandler from './activity-handler.react'; +import APIRequestHandler from './api-request-handler.react'; +import CalendarQueryHandler from './calendar-query-handler.react'; import { InflightRequests, SocketTimeout, SocketOffline, } from './inflight-requests'; -import APIRequestHandler from './api-request-handler.react'; -import ActivityHandler from './activity-handler.react'; -import RequestResponseHandler from './request-response-handler.react'; -import UpdateHandler from './update-handler.react'; import MessageHandler from './message-handler.react'; -import CalendarQueryHandler from './calendar-query-handler.react'; import ReportHandler from './report-handler.react'; -import { updateActivityActionTypes } from '../actions/activity-actions'; -import { unsupervisedBackgroundActionType } from '../reducers/foreground-reducer'; -import { actionLogger } from '../utils/action-logger'; -import sleep from '../utils/sleep'; +import RequestResponseHandler from './request-response-handler.react'; +import UpdateHandler from './update-handler.react'; const remainingTimeAfterVisualTimeout = clientRequestSocketTimeout - clientRequestVisualTimeout; export type BaseSocketProps = {| +detectUnsupervisedBackgroundRef?: ( detectUnsupervisedBackground: (alreadyClosed: boolean) => boolean, ) => void, |}; type Props = {| ...BaseSocketProps, // Redux state +active: boolean, +openSocket: () => WebSocket, +getClientResponses: ( activeServerRequests: $ReadOnlyArray, ) => $ReadOnlyArray, +activeThread: ?string, +sessionStateFunc: () => SessionState, +sessionIdentification: SessionIdentification, +cookie: ?string, +urlPrefix: string, +connection: ConnectionInfo, +currentCalendarQuery: () => CalendarQuery, +canSendReports: boolean, +frozen: boolean, +preRequestUserState: PreRequestUserState, // Redux dispatch functions +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +logOut: (preRequestUserState: PreRequestUserState) => Promise, |}; type State = {| +inflightRequests: ?InflightRequests, |}; class Socket extends React.PureComponent { static propTypes = { detectUnsupervisedBackgroundRef: PropTypes.func, active: PropTypes.bool.isRequired, openSocket: PropTypes.func.isRequired, getClientResponses: PropTypes.func.isRequired, activeThread: PropTypes.string, sessionStateFunc: PropTypes.func.isRequired, sessionIdentification: sessionIdentificationPropType.isRequired, cookie: PropTypes.string, urlPrefix: PropTypes.string.isRequired, connection: connectionInfoPropType.isRequired, currentCalendarQuery: PropTypes.func.isRequired, canSendReports: PropTypes.bool.isRequired, frozen: PropTypes.bool.isRequired, preRequestUserState: preRequestUserStatePropType.isRequired, dispatch: PropTypes.func.isRequired, dispatchActionPromise: PropTypes.func.isRequired, logOut: PropTypes.func.isRequired, }; state: State = { inflightRequests: null, }; socket: ?WebSocket; nextClientMessageID = 0; listeners: Set = new Set(); pingTimeoutID: ?TimeoutID; messageLastReceived: ?number; initialPlatformDetailsSent = getConfig().platformDetails.platform === 'web'; reopenConnectionAfterClosing = false; invalidationRecoveryInProgress = false; initializedWithUserState: ?PreRequestUserState; openSocket(newStatus: ConnectionStatus) { if ( this.props.frozen || (getConfig().platformDetails.platform !== 'web' && (!this.props.cookie || !this.props.cookie.startsWith('user='))) ) { return; } if (this.socket) { const { status } = this.props.connection; if (status === 'forcedDisconnecting') { this.reopenConnectionAfterClosing = true; return; } else if (status === 'disconnecting' && this.socket.readyState === 1) { this.markSocketInitialized(); return; } else if ( status === 'connected' || status === 'connecting' || status === 'reconnecting' ) { return; } if (this.socket.readyState < 2) { this.socket.close(); console.log(`this.socket seems open, but Redux thinks it's ${status}`); } } this.props.dispatch({ type: updateConnectionStatusActionType, payload: { status: newStatus }, }); const socket = this.props.openSocket(); const openObject = {}; socket.onopen = () => { if (this.socket === socket) { this.initializeSocket(); openObject.initializeMessageSent = true; } }; socket.onmessage = this.receiveMessage; socket.onclose = () => { if (this.socket === socket) { this.onClose(); } }; this.socket = socket; (async () => { await sleep(clientRequestVisualTimeout); if (this.socket !== socket || openObject.initializeMessageSent) { return; } this.setLateResponse(-1, true); await sleep(remainingTimeAfterVisualTimeout); if (this.socket !== socket || openObject.initializeMessageSent) { return; } this.finishClosingSocket(); })(); this.setState({ inflightRequests: new InflightRequests({ timeout: () => { if (this.socket === socket) { this.finishClosingSocket(); } }, setLateResponse: (messageID: number, isLate: boolean) => { if (this.socket === socket) { this.setLateResponse(messageID, isLate); } }, }), }); } markSocketInitialized() { this.props.dispatch({ type: updateConnectionStatusActionType, payload: { status: 'connected' }, }); this.resetPing(); } closeSocket( // This param is a hack. When closing a socket there is a race between this // function and the one to propagate the activity update. We make sure that // the activity update wins the race by passing in this param. activityUpdatePending: boolean, ) { const { status } = this.props.connection; if (status === 'disconnected') { return; } else if (status === 'disconnecting' || status === 'forcedDisconnecting') { this.reopenConnectionAfterClosing = false; return; } this.stopPing(); this.props.dispatch({ type: updateConnectionStatusActionType, payload: { status: 'disconnecting' }, }); if (!activityUpdatePending) { this.finishClosingSocket(); } } forceCloseSocket() { this.stopPing(); const { status } = this.props.connection; if (status !== 'forcedDisconnecting' && status !== 'disconnected') { this.props.dispatch({ type: updateConnectionStatusActionType, payload: { status: 'forcedDisconnecting' }, }); } this.finishClosingSocket(); } finishClosingSocket(receivedResponseTo?: ?number) { const { inflightRequests } = this.state; if ( inflightRequests && !inflightRequests.allRequestsResolvedExcept(receivedResponseTo) ) { return; } if (this.socket && this.socket.readyState < 2) { // If it's not closing already, close it this.socket.close(); } this.socket = null; this.stopPing(); this.setState({ inflightRequests: null }); if (this.props.connection.status !== 'disconnected') { this.props.dispatch({ type: updateConnectionStatusActionType, payload: { status: 'disconnected' }, }); } if (this.reopenConnectionAfterClosing) { this.reopenConnectionAfterClosing = false; if (this.props.active) { this.openSocket('connecting'); } } } reconnect = _throttle(() => this.openSocket('reconnecting'), 2000); componentDidMount() { if (this.props.detectUnsupervisedBackgroundRef) { this.props.detectUnsupervisedBackgroundRef( this.detectUnsupervisedBackground, ); } if (this.props.active) { this.openSocket('connecting'); } } componentWillUnmount() { this.closeSocket(false); this.reconnect.cancel(); } componentDidUpdate(prevProps: Props) { if (this.props.active && !prevProps.active) { this.openSocket('connecting'); } else if (!this.props.active && prevProps.active) { this.closeSocket(!!prevProps.activeThread); } else if ( this.props.active && prevProps.openSocket !== this.props.openSocket ) { // This case happens when the baseURL/urlPrefix is changed this.reopenConnectionAfterClosing = true; this.forceCloseSocket(); } else if ( this.props.active && this.props.connection.status === 'disconnected' && prevProps.connection.status !== 'disconnected' && !this.invalidationRecoveryInProgress ) { this.reconnect(); } } render() { // It's important that APIRequestHandler get rendered first here. This is so // that it is registered with Redux first, so that its componentDidUpdate // processes before the other Handlers. This allows APIRequestHandler to // register itself with action-utils before other Handlers call // dispatchActionPromise in response to the componentDidUpdate triggered by // the same Redux change (state.connection.status). return ( ); } sendMessageWithoutID = (message: ClientSocketMessageWithoutID) => { const id = this.nextClientMessageID++; // These conditions all do the same thing and the runtime checks are only // necessary for Flow if (message.type === clientSocketMessageTypes.INITIAL) { this.sendMessage(({ ...message, id }: ClientInitialClientSocketMessage)); } else if (message.type === clientSocketMessageTypes.RESPONSES) { this.sendMessage( ({ ...message, id }: ClientResponsesClientSocketMessage), ); } else if (message.type === clientSocketMessageTypes.PING) { this.sendMessage(({ ...message, id }: PingClientSocketMessage)); } else if (message.type === clientSocketMessageTypes.ACK_UPDATES) { this.sendMessage(({ ...message, id }: AckUpdatesClientSocketMessage)); } else if (message.type === clientSocketMessageTypes.API_REQUEST) { this.sendMessage(({ ...message, id }: APIRequestClientSocketMessage)); } return id; }; sendMessage(message: ClientClientSocketMessage) { const socket = this.socket; invariant(socket, 'should be set'); socket.send(JSON.stringify(message)); } static messageFromEvent(event: MessageEvent): ?ServerSocketMessage { if (typeof event.data !== 'string') { console.log('socket received a non-string message'); return null; } try { return JSON.parse(event.data); } catch (e) { console.log(e); return null; } } receiveMessage = async (event: MessageEvent) => { const message = Socket.messageFromEvent(event); if (!message) { return; } const { inflightRequests } = this.state; if (!inflightRequests) { // inflightRequests can be falsey here if we receive a message after we've // begun shutting down the socket. It's possible for a React Native // WebSocket to deliver a message even after close() is called on it. In // this case the message is probably a PONG, which we can safely ignore. // If it's not a PONG, it has to be something server-initiated (like // UPDATES or MESSAGES), since InflightRequests.allRequestsResolvedExcept // will wait for all responses to client-initiated requests to be // delivered before closing a socket. UPDATES and MESSAGES are both // checkpointed on the client, so should be okay to just ignore here and // redownload them later, probably in an incremental STATE_SYNC. return; } // If we receive any message, that indicates that our connection is healthy, // so we can reset the ping timeout. this.resetPing(); inflightRequests.resolveRequestsForMessage(message); const { status } = this.props.connection; if (status === 'disconnecting' || status === 'forcedDisconnecting') { this.finishClosingSocket( // We do this for Flow message.responseTo !== undefined ? message.responseTo : null, ); } for (let listener of this.listeners) { listener(message); } if (message.type === serverSocketMessageTypes.ERROR) { const { message: errorMessage, payload } = message; if (payload) { console.log(`socket sent error ${errorMessage} with payload`, payload); } else { console.log(`socket sent error ${errorMessage}`); } } else if (message.type === serverSocketMessageTypes.AUTH_ERROR) { const { sessionChange } = message; const cookie = sessionChange ? sessionChange.cookie : this.props.cookie; this.invalidationRecoveryInProgress = true; const recoverySessionChange = await fetchNewCookieFromNativeCredentials( this.props.dispatch, cookie, this.props.urlPrefix, socketAuthErrorResolutionAttempt, ); if (!recoverySessionChange && sessionChange) { // This should only happen in the cookieSources.BODY (native) case when // the resolution attempt failed const { cookie: newerCookie, currentUserInfo } = sessionChange; this.props.dispatch({ type: setNewSessionActionType, payload: { sessionChange: { cookieInvalidated: true, currentUserInfo, cookie: newerCookie, }, preRequestUserState: this.initializedWithUserState, error: null, source: socketAuthErrorResolutionAttempt, }, }); } else if (!recoverySessionChange) { this.props.dispatchActionPromise( logOutActionTypes, this.props.logOut(this.props.preRequestUserState), ); } this.invalidationRecoveryInProgress = false; } }; addListener = (listener: SocketListener) => { this.listeners.add(listener); }; removeListener = (listener: SocketListener) => { this.listeners.delete(listener); }; onClose = () => { const { status } = this.props.connection; this.socket = null; this.stopPing(); if (this.state.inflightRequests) { this.state.inflightRequests.rejectAll(new Error('socket closed')); this.setState({ inflightRequests: null }); } const handled = this.detectUnsupervisedBackground(true); if (!handled && status !== 'disconnected') { this.props.dispatch({ type: updateConnectionStatusActionType, payload: { status: 'disconnected' }, }); } }; async sendInitialMessage() { const { inflightRequests } = this.state; invariant( inflightRequests, 'inflightRequests falsey inside sendInitialMessage', ); const messageID = this.nextClientMessageID++; const promises = {}; const clientResponses = []; if (!this.initialPlatformDetailsSent) { this.initialPlatformDetailsSent = true; clientResponses.push({ type: serverRequestTypes.PLATFORM_DETAILS, platformDetails: getConfig().platformDetails, }); } const { queuedActivityUpdates } = this.props.connection; if (queuedActivityUpdates.length > 0) { clientResponses.push({ type: serverRequestTypes.INITIAL_ACTIVITY_UPDATES, activityUpdates: queuedActivityUpdates, }); promises.activityUpdateMessage = inflightRequests.fetchResponse( messageID, serverSocketMessageTypes.ACTIVITY_UPDATE_RESPONSE, ); } const sessionState = this.props.sessionStateFunc(); const { sessionIdentification } = this.props; const initialMessage = { type: clientSocketMessageTypes.INITIAL, id: messageID, payload: { clientResponses, sessionState, sessionIdentification, }, }; this.initializedWithUserState = this.props.preRequestUserState; this.sendMessage(initialMessage); promises.stateSyncMessage = inflightRequests.fetchResponse( messageID, serverSocketMessageTypes.STATE_SYNC, ); const { stateSyncMessage, activityUpdateMessage } = await promiseAll( promises, ); if (activityUpdateMessage) { this.props.dispatch({ type: updateActivityActionTypes.success, payload: { activityUpdates: queuedActivityUpdates, result: activityUpdateMessage.payload, }, }); } if (stateSyncMessage.payload.type === stateSyncPayloadTypes.FULL) { const { sessionID, type, ...actionPayload } = stateSyncMessage.payload; this.props.dispatch({ type: fullStateSyncActionType, payload: { ...actionPayload, calendarQuery: sessionState.calendarQuery, }, }); if (sessionID !== null && sessionID !== undefined) { invariant( this.initializedWithUserState, 'initializedWithUserState should be set when state sync received', ); this.props.dispatch({ type: setNewSessionActionType, payload: { sessionChange: { cookieInvalidated: false, sessionID }, preRequestUserState: this.initializedWithUserState, error: null, source: undefined, }, }); } } else { const { type, ...actionPayload } = stateSyncMessage.payload; this.props.dispatch({ type: incrementalStateSyncActionType, payload: { ...actionPayload, calendarQuery: sessionState.calendarQuery, }, }); } const currentAsOf = stateSyncMessage.payload.type === stateSyncPayloadTypes.FULL ? stateSyncMessage.payload.updatesCurrentAsOf : stateSyncMessage.payload.updatesResult.currentAsOf; this.sendMessageWithoutID({ type: clientSocketMessageTypes.ACK_UPDATES, payload: { currentAsOf }, }); this.markSocketInitialized(); } initializeSocket = async (retriesLeft: number = 1) => { try { await this.sendInitialMessage(); } catch (e) { console.log(e); const { status } = this.props.connection; if ( e instanceof SocketTimeout || e instanceof SocketOffline || (status !== 'connecting' && status !== 'reconnecting') ) { // This indicates that the socket will be closed. Do nothing, since the // connection status update will trigger a reconnect. } else if ( retriesLeft === 0 || (e instanceof ServerError && e.message !== 'unknown_error') ) { if (e.message === 'not_logged_in') { this.props.dispatchActionPromise( logOutActionTypes, this.props.logOut(this.props.preRequestUserState), ); } else if (this.socket) { this.socket.close(); } } else { await this.initializeSocket(retriesLeft - 1); } } }; stopPing() { if (this.pingTimeoutID) { clearTimeout(this.pingTimeoutID); this.pingTimeoutID = null; } } resetPing() { this.stopPing(); const socket = this.socket; this.messageLastReceived = Date.now(); this.pingTimeoutID = setTimeout(() => { if (this.socket === socket) { this.sendPing(); } }, pingFrequency); } async sendPing() { if (this.props.connection.status !== 'connected') { // This generally shouldn't happen because anything that changes the // connection status should call stopPing(), but it's good to make sure return; } const messageID = this.sendMessageWithoutID({ type: clientSocketMessageTypes.PING, }); try { invariant( this.state.inflightRequests, 'inflightRequests falsey inside sendPing', ); await this.state.inflightRequests.fetchResponse( messageID, serverSocketMessageTypes.PONG, ); } catch (e) {} } setLateResponse = (messageID: number, isLate: boolean) => { this.props.dispatch({ type: setLateResponseActionType, payload: { messageID, isLate }, }); }; cleanUpServerTerminatedSocket() { if (this.socket && this.socket.readyState < 2) { this.socket.close(); } else { this.onClose(); } } detectUnsupervisedBackground = (alreadyClosed: boolean) => { // On native, sometimes the app is backgrounded without the proper callbacks // getting triggered. This leaves us in an incorrect state for two reasons: // (1) The connection is still considered to be active, causing API requests // to be processed via socket and failing. // (2) We rely on flipping foreground state in Redux to detect activity // changes, and thus won't think we need to update activity. if ( this.props.connection.status !== 'connected' || !this.messageLastReceived || this.messageLastReceived + serverRequestSocketTimeout >= Date.now() || (actionLogger.mostRecentActionTime && actionLogger.mostRecentActionTime + 3000 < Date.now()) ) { return false; } if (!alreadyClosed) { this.cleanUpServerTerminatedSocket(); } this.props.dispatch({ type: unsupervisedBackgroundActionType, payload: null, }); return true; }; } export default Socket; diff --git a/lib/socket/update-handler.react.js b/lib/socket/update-handler.react.js index 7a9946ad4..14cc398a1 100644 --- a/lib/socket/update-handler.react.js +++ b/lib/socket/update-handler.react.js @@ -1,74 +1,73 @@ // @flow +import PropTypes from 'prop-types'; +import * as React from 'react'; + +import type { AppState } from '../types/redux-types'; import { type ClientSocketMessageWithoutID, type SocketListener, type ConnectionInfo, connectionInfoPropType, type ServerSocketMessage, serverSocketMessageTypes, clientSocketMessageTypes, } from '../types/socket-types'; import { processUpdatesActionType } from '../types/update-types'; import type { DispatchActionPayload } from '../utils/action-utils'; -import type { AppState } from '../types/redux-types'; - -import * as React from 'react'; -import PropTypes from 'prop-types'; - import { connect } from '../utils/redux-utils'; type Props = {| sendMessage: (message: ClientSocketMessageWithoutID) => number, addListener: (listener: SocketListener) => void, removeListener: (listener: SocketListener) => void, // Redux state connection: ConnectionInfo, // Redux dispatch functions dispatchActionPayload: DispatchActionPayload, |}; class UpdateHandler extends React.PureComponent { static propTypes = { sendMessage: PropTypes.func.isRequired, addListener: PropTypes.func.isRequired, removeListener: PropTypes.func.isRequired, connection: connectionInfoPropType.isRequired, dispatchActionPayload: PropTypes.func.isRequired, }; componentDidMount() { this.props.addListener(this.onMessage); } componentWillUnmount() { this.props.removeListener(this.onMessage); } render() { return null; } onMessage = (message: ServerSocketMessage) => { if (message.type !== serverSocketMessageTypes.UPDATES) { return; } this.props.dispatchActionPayload(processUpdatesActionType, message.payload); if (this.props.connection.status !== 'connected') { return; } this.props.sendMessage({ type: clientSocketMessageTypes.ACK_UPDATES, payload: { currentAsOf: message.payload.updatesResult.currentAsOf, }, }); }; } export default connect( (state: AppState) => ({ connection: state.connection, }), null, true, )(UpdateHandler); diff --git a/lib/types/account-types.js b/lib/types/account-types.js index 43c1e688a..9509872cb 100644 --- a/lib/types/account-types.js +++ b/lib/types/account-types.js @@ -1,152 +1,152 @@ // @flow -import type { RawThreadInfo } from './thread-types'; -import type { - UserInfo, - LoggedOutUserInfo, - LoggedInUserInfo, -} from './user-types'; +import type { PlatformDetails, DeviceType } from './device-types'; import type { CalendarQuery, CalendarResult, RawEntryInfo, } from './entry-types'; import type { RawMessageInfo, MessageTruncationStatuses, GenericMessagesResult, } from './message-types'; -import type { PlatformDetails, DeviceType } from './device-types'; import type { PreRequestUserState } from './session-types'; +import type { RawThreadInfo } from './thread-types'; +import type { + UserInfo, + LoggedOutUserInfo, + LoggedInUserInfo, +} from './user-types'; export type ResetPasswordRequest = {| usernameOrEmail: string, |}; export type LogOutResult = {| currentUserInfo: ?LoggedOutUserInfo, preRequestUserState: PreRequestUserState, |}; export type LogOutResponse = {| currentUserInfo: LoggedOutUserInfo, |}; export type RegisterInfo = {| ...LogInExtraInfo, username: string, email: string, password: string, |}; type DeviceTokenUpdateRequest = {| deviceToken: string, |}; export type RegisterRequest = {| username: string, email: string, password: string, calendarQuery?: ?CalendarQuery, deviceTokenUpdateRequest?: ?DeviceTokenUpdateRequest, platformDetails: PlatformDetails, |}; export type RegisterResponse = {| id: string, rawMessageInfos: $ReadOnlyArray, cookieChange: { threadInfos: { [id: string]: RawThreadInfo }, userInfos: $ReadOnlyArray, }, |}; export type RegisterResult = {| currentUserInfo: LoggedInUserInfo, rawMessageInfos: $ReadOnlyArray, threadInfos: { [id: string]: RawThreadInfo }, userInfos: $ReadOnlyArray, calendarQuery: CalendarQuery, |}; export type DeleteAccountRequest = {| password: string, |}; export type ChangeUserSettingsResult = {| email: ?string, |}; export type LogInActionSource = | 'COOKIE_INVALIDATION_RESOLUTION_ATTEMPT' | 'APP_START_NATIVE_CREDENTIALS_AUTO_LOG_IN' | 'APP_START_REDUX_LOGGED_IN_BUT_INVALID_COOKIE' | 'SOCKET_AUTH_ERROR_RESOLUTION_ATTEMPT'; export type LogInStartingPayload = {| calendarQuery: CalendarQuery, source?: LogInActionSource, |}; export type LogInExtraInfo = {| calendarQuery: CalendarQuery, deviceTokenUpdateRequest?: ?DeviceTokenUpdateRequest, |}; export type LogInInfo = {| ...LogInExtraInfo, usernameOrEmail: string, password: string, source?: ?LogInActionSource, |}; export type LogInRequest = {| usernameOrEmail: string, password: string, calendarQuery?: ?CalendarQuery, deviceTokenUpdateRequest?: ?DeviceTokenUpdateRequest, platformDetails: PlatformDetails, watchedIDs: $ReadOnlyArray, |}; export type LogInResponse = {| currentUserInfo: LoggedInUserInfo, rawMessageInfos: RawMessageInfo[], truncationStatuses: MessageTruncationStatuses, userInfos: $ReadOnlyArray, rawEntryInfos?: ?$ReadOnlyArray, serverTime: number, cookieChange: { threadInfos: { [id: string]: RawThreadInfo }, userInfos: $ReadOnlyArray, }, |}; export type LogInResult = {| threadInfos: { [id: string]: RawThreadInfo }, currentUserInfo: LoggedInUserInfo, messagesResult: GenericMessagesResult, userInfos: UserInfo[], calendarResult: CalendarResult, updatesCurrentAsOf: number, source?: ?LogInActionSource, |}; export type UpdatePasswordInfo = {| ...LogInExtraInfo, code: string, password: string, |}; export type UpdatePasswordRequest = {| code: string, password: string, calendarQuery?: ?CalendarQuery, deviceTokenUpdateRequest?: ?DeviceTokenUpdateRequest, platformDetails: PlatformDetails, watchedIDs: $ReadOnlyArray, |}; export type AccessRequest = {| email: string, platform: DeviceType, |}; diff --git a/lib/types/entry-types.js b/lib/types/entry-types.js index 5f25e0284..5c7dbff4c 100644 --- a/lib/types/entry-types.js +++ b/lib/types/entry-types.js @@ -1,232 +1,232 @@ // @flow -import type { RawMessageInfo } from './message-types'; -import type { AccountUserInfo } from './user-types'; -import { - type CalendarFilter, - calendarFilterPropType, - defaultCalendarFilters, -} from './filter-types'; -import type { CreateUpdatesResponse } from './update-types'; -import type { Platform } from './device-types'; -import type { ClientEntryInconsistencyReportCreationRequest } from './report-types'; - import PropTypes from 'prop-types'; import { fifteenDaysEarlier, fifteenDaysLater, thisMonthDates, } from '../utils/date-utils'; +import type { Platform } from './device-types'; +import { + type CalendarFilter, + calendarFilterPropType, + defaultCalendarFilters, +} from './filter-types'; +import type { RawMessageInfo } from './message-types'; +import type { ClientEntryInconsistencyReportCreationRequest } from './report-types'; +import type { CreateUpdatesResponse } from './update-types'; +import type { AccountUserInfo } from './user-types'; + export type RawEntryInfo = {| id?: string, // null if local copy without ID yet localID?: string, // for optimistic creations threadID: string, text: string, year: number, month: number, // 1-indexed day: number, // 1-indexed creationTime: number, // millisecond timestamp creatorID: string, deleted: boolean, |}; export const rawEntryInfoPropType = PropTypes.shape({ id: PropTypes.string, localID: PropTypes.string, threadID: PropTypes.string.isRequired, text: PropTypes.string.isRequired, year: PropTypes.number.isRequired, month: PropTypes.number.isRequired, day: PropTypes.number.isRequired, creationTime: PropTypes.number.isRequired, creatorID: PropTypes.string.isRequired, deleted: PropTypes.bool.isRequired, }); export type EntryInfo = {| id?: string, // null if local copy without ID yet localID?: string, // for optimistic creations threadID: string, text: string, year: number, month: number, // 1-indexed day: number, // 1-indexed creationTime: number, // millisecond timestamp creator: ?string, deleted: boolean, |}; export const entryInfoPropType = PropTypes.shape({ id: PropTypes.string, localID: PropTypes.string, threadID: PropTypes.string.isRequired, text: PropTypes.string.isRequired, year: PropTypes.number.isRequired, month: PropTypes.number.isRequired, day: PropTypes.number.isRequired, creationTime: PropTypes.number.isRequired, creator: PropTypes.string, deleted: PropTypes.bool.isRequired, }); export type EntryStore = {| entryInfos: { [id: string]: RawEntryInfo }, daysToEntries: { [day: string]: string[] }, lastUserInteractionCalendar: number, inconsistencyReports: $ReadOnlyArray, |}; export type CalendarQuery = {| startDate: string, endDate: string, filters: $ReadOnlyArray, |}; export const defaultCalendarQuery = ( platform: ?Platform, timeZone?: ?string, ) => { if (platform === 'web') { return { ...thisMonthDates(timeZone), filters: defaultCalendarFilters, }; } else { return { startDate: fifteenDaysEarlier(timeZone).valueOf(), endDate: fifteenDaysLater(timeZone).valueOf(), filters: defaultCalendarFilters, }; } }; export const calendarQueryPropType = PropTypes.shape({ startDate: PropTypes.string.isRequired, endDate: PropTypes.string.isRequired, filters: PropTypes.arrayOf(calendarFilterPropType).isRequired, }); export type SaveEntryInfo = {| entryID: string, text: string, prevText: string, timestamp: number, calendarQuery: CalendarQuery, |}; export type SaveEntryRequest = {| entryID: string, text: string, prevText: string, timestamp: number, calendarQuery?: CalendarQuery, |}; export type SaveEntryResponse = {| entryID: string, newMessageInfos: $ReadOnlyArray, updatesResult: CreateUpdatesResponse, |}; export type SaveEntryPayload = {| ...SaveEntryResponse, threadID: string, |}; export type CreateEntryInfo = {| text: string, timestamp: number, date: string, threadID: string, localID: string, calendarQuery: CalendarQuery, |}; export type CreateEntryRequest = {| text: string, timestamp: number, date: string, threadID: string, localID?: string, calendarQuery?: CalendarQuery, |}; export type CreateEntryPayload = {| ...SaveEntryPayload, localID: string, |}; export type DeleteEntryInfo = {| entryID: string, prevText: string, calendarQuery: CalendarQuery, |}; export type DeleteEntryRequest = {| entryID: string, prevText: string, timestamp: number, calendarQuery?: CalendarQuery, |}; export type RestoreEntryInfo = {| entryID: string, calendarQuery: CalendarQuery, |}; export type RestoreEntryRequest = {| entryID: string, timestamp: number, calendarQuery?: CalendarQuery, |}; export type DeleteEntryResponse = {| newMessageInfos: $ReadOnlyArray, threadID: string, updatesResult: CreateUpdatesResponse, |}; export type RestoreEntryResponse = {| newMessageInfos: $ReadOnlyArray, updatesResult: CreateUpdatesResponse, |}; export type RestoreEntryPayload = {| ...RestoreEntryResponse, threadID: string, |}; export type FetchEntryInfosBase = {| rawEntryInfos: $ReadOnlyArray, |}; export type FetchEntryInfosResponse = {| ...FetchEntryInfosBase, userInfos: { [id: string]: AccountUserInfo }, |}; export type FetchEntryInfosResult = FetchEntryInfosBase; export type DeltaEntryInfosResponse = {| rawEntryInfos: $ReadOnlyArray, deletedEntryIDs: $ReadOnlyArray, |}; export type DeltaEntryInfosResult = {| rawEntryInfos: $ReadOnlyArray, deletedEntryIDs: $ReadOnlyArray, userInfos: $ReadOnlyArray, |}; export type CalendarResult = {| rawEntryInfos: $ReadOnlyArray, calendarQuery: CalendarQuery, |}; export type CalendarQueryUpdateStartingPayload = {| calendarQuery?: CalendarQuery, |}; export type CalendarQueryUpdateResult = {| rawEntryInfos: $ReadOnlyArray, deletedEntryIDs: $ReadOnlyArray, calendarQuery: CalendarQuery, calendarQueryAlreadyUpdated: boolean, |}; diff --git a/lib/types/filter-types.js b/lib/types/filter-types.js index 0c721b96c..29593e930 100644 --- a/lib/types/filter-types.js +++ b/lib/types/filter-types.js @@ -1,51 +1,51 @@ // @flow -import { type ThreadInfo, threadInfoPropType } from './thread-types'; - import PropTypes from 'prop-types'; +import { type ThreadInfo, threadInfoPropType } from './thread-types'; + export const calendarThreadFilterTypes = Object.freeze({ THREAD_LIST: 'threads', NOT_DELETED: 'not_deleted', }); export type CalendarThreadFilterType = $Values< typeof calendarThreadFilterTypes, >; export type CalendarThreadFilter = {| type: 'threads', threadIDs: $ReadOnlyArray, |}; export type CalendarFilter = {| type: 'not_deleted' |} | CalendarThreadFilter; export const calendarFilterPropType = PropTypes.oneOfType([ PropTypes.shape({ type: PropTypes.oneOf([calendarThreadFilterTypes.NOT_DELETED]).isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([calendarThreadFilterTypes.THREAD_LIST]).isRequired, threadIDs: PropTypes.arrayOf(PropTypes.string).isRequired, }), ]); export const defaultCalendarFilters: $ReadOnlyArray = [ { type: calendarThreadFilterTypes.NOT_DELETED }, ]; export const updateCalendarThreadFilter = 'UPDATE_CALENDAR_THREAD_FILTER'; export const clearCalendarThreadFilter = 'CLEAR_CALENDAR_THREAD_FILTER'; export const setCalendarDeletedFilter = 'SET_CALENDAR_DELETED_FILTER'; export type SetCalendarDeletedFilterPayload = {| includeDeleted: boolean, |}; export type FilterThreadInfo = {| threadInfo: ThreadInfo, numVisibleEntries: number, |}; export const filterThreadInfoPropType = PropTypes.shape({ threadInfo: threadInfoPropType.isRequired, numVisibleEntries: PropTypes.number.isRequired, }); diff --git a/lib/types/media-types.js b/lib/types/media-types.js index d890ce21c..88e19db39 100644 --- a/lib/types/media-types.js +++ b/lib/types/media-types.js @@ -1,899 +1,899 @@ // @flow -import { type Platform, platformPropType } from './device-types'; - import PropTypes from 'prop-types'; +import { type Platform, platformPropType } from './device-types'; + export type Dimensions = $ReadOnly<{| height: number, width: number, |}>; export const dimensionsPropType = PropTypes.shape({ height: PropTypes.number.isRequired, width: PropTypes.number.isRequired, }); export type MediaType = 'photo' | 'video'; export type Image = {| id: string, uri: string, type: 'photo', dimensions: Dimensions, // stored on native only during creation in case retry needed after state lost localMediaSelection?: NativeMediaSelection, |}; export type Video = {| id: string, uri: string, type: 'video', dimensions: Dimensions, loop?: boolean, // stored on native only during creation in case retry needed after state lost localMediaSelection?: NativeMediaSelection, |}; export type Media = Image | Video; export type Corners = $Shape<{| topLeft: boolean, topRight: boolean, bottomLeft: boolean, bottomRight: boolean, |}>; export type MediaInfo = | {| ...Image, corners: Corners, index: number, |} | {| ...Video, corners: Corners, index: number, |}; export const mediaTypePropType = PropTypes.oneOf(['photo', 'video']); const mediaPropTypes = { id: PropTypes.string.isRequired, uri: PropTypes.string.isRequired, type: mediaTypePropType.isRequired, dimensions: dimensionsPropType.isRequired, filename: PropTypes.string, }; export const mediaPropType = PropTypes.shape(mediaPropTypes); export const cornersPropType = PropTypes.shape({ topLeft: PropTypes.bool, topRight: PropTypes.bool, bottomLeft: PropTypes.bool, bottomRight: PropTypes.bool, }); export const mediaInfoPropType = PropTypes.shape({ ...mediaPropTypes, corners: cornersPropType.isRequired, index: PropTypes.number.isRequired, }); export type UploadMultimediaResult = {| id: string, uri: string, dimensions: Dimensions, mediaType: MediaType, loop: boolean, |}; export type UpdateMultimediaMessageMediaPayload = {| messageID: string, currentMediaID: string, mediaUpdate: $Shape, |}; export type UploadDeletionRequest = {| id: string, |}; export type FFmpegStatistics = {| // seconds of video being processed per second +speed: number, // total milliseconds of video processed so far +time: number, // total result file size in bytes so far +size: number, +videoQuality: number, +videoFrameNumber: number, +videoFps: number, +bitrate: number, |}; export type VideoProbeMediaMissionStep = {| step: 'video_probe', success: boolean, exceptionMessage: ?string, time: number, // ms path: string, validFormat: boolean, duration: ?number, // seconds codec: ?string, format: ?$ReadOnlyArray, dimensions: ?Dimensions, |}; export type ReadFileHeaderMediaMissionStep = {| step: 'read_file_header', success: boolean, exceptionMessage: ?string, time: number, // ms uri: string, mime: ?string, mediaType: ?MediaType, |}; export type DetermineFileTypeMediaMissionStep = {| step: 'determine_file_type', success: boolean, exceptionMessage: ?string, time: number, // ms inputFilename: string, outputMIME: ?string, outputMediaType: ?MediaType, outputFilename: ?string, |}; export type FrameCountMediaMissionStep = {| step: 'frame_count', success: boolean, exceptionMessage: ?string, time: number, path: string, mime: string, hasMultipleFrames: ?boolean, |}; export type DisposeTemporaryFileMediaMissionStep = {| step: 'dispose_temporary_file', success: boolean, exceptionMessage: ?string, time: number, // ms path: string, |}; export type MakeDirectoryMediaMissionStep = {| step: 'make_directory', success: boolean, exceptionMessage: ?string, time: number, // ms path: string, |}; export type AndroidScanFileMediaMissionStep = {| step: 'android_scan_file', success: boolean, exceptionMessage: ?string, time: number, // ms path: string, |}; export type FetchFileHashMediaMissionStep = {| step: 'fetch_file_hash', success: boolean, exceptionMessage: ?string, time: number, // ms path: string, hash: ?string, |}; export type CopyFileMediaMissionStep = {| step: 'copy_file', success: boolean, exceptionMessage: ?string, time: number, // ms source: string, destination: string, |}; export type GetOrientationMediaMissionStep = {| step: 'exif_fetch', success: boolean, exceptionMessage: ?string, time: number, // ms orientation: ?number, |}; export type MediaLibrarySelection = | {| step: 'photo_library', dimensions: Dimensions, filename: string, uri: string, mediaNativeID: string, selectTime: number, // ms timestamp sendTime: number, // ms timestamp retries: number, |} | {| step: 'video_library', dimensions: Dimensions, filename: string, uri: string, mediaNativeID: string, selectTime: number, // ms timestamp sendTime: number, // ms timestamp retries: number, duration: number, // seconds |}; const photoLibrarySelectionPropType = PropTypes.shape({ step: PropTypes.oneOf(['photo_library']).isRequired, dimensions: dimensionsPropType.isRequired, filename: PropTypes.string.isRequired, uri: PropTypes.string.isRequired, mediaNativeID: PropTypes.string.isRequired, selectTime: PropTypes.number.isRequired, sendTime: PropTypes.number.isRequired, retries: PropTypes.number.isRequired, }); const videoLibrarySelectionPropType = PropTypes.shape({ step: PropTypes.oneOf(['video_library']).isRequired, dimensions: dimensionsPropType.isRequired, filename: PropTypes.string.isRequired, uri: PropTypes.string.isRequired, mediaNativeID: PropTypes.string.isRequired, selectTime: PropTypes.number.isRequired, sendTime: PropTypes.number.isRequired, retries: PropTypes.number.isRequired, duration: PropTypes.number.isRequired, }); export const mediaLibrarySelectionPropType = PropTypes.oneOfType([ photoLibrarySelectionPropType, videoLibrarySelectionPropType, ]); export type PhotoCapture = {| step: 'photo_capture', time: number, // ms dimensions: Dimensions, filename: string, uri: string, captureTime: number, // ms timestamp selectTime: number, // ms timestamp sendTime: number, // ms timestamp retries: number, |}; export type NativeMediaSelection = MediaLibrarySelection | PhotoCapture; export type MediaMissionStep = | NativeMediaSelection | {| step: 'web_selection', filename: string, size: number, // in bytes mime: string, selectTime: number, // ms timestamp |} | {| step: 'asset_info_fetch', success: boolean, exceptionMessage: ?string, time: number, // ms localURI: ?string, orientation: ?number, |} | {| step: 'stat_file', success: boolean, exceptionMessage: ?string, time: number, // ms uri: string, fileSize: ?number, |} | ReadFileHeaderMediaMissionStep | DetermineFileTypeMediaMissionStep | FrameCountMediaMissionStep | {| step: 'photo_manipulation', success: boolean, exceptionMessage: ?string, time: number, // ms manipulation: Object, newMIME: ?string, newDimensions: ?Dimensions, newURI: ?string, |} | VideoProbeMediaMissionStep | {| step: 'video_ffmpeg_transcode', success: boolean, exceptionMessage: ?string, time: number, // ms returnCode: ?number, newPath: ?string, stats: ?FFmpegStatistics, |} | DisposeTemporaryFileMediaMissionStep | {| step: 'save_media', uri: string, time: number, // ms timestamp |} | {| step: 'permissions_check', success: boolean, exceptionMessage: ?string, time: number, // ms platform: Platform, permissions: $ReadOnlyArray, |} | MakeDirectoryMediaMissionStep | AndroidScanFileMediaMissionStep | {| step: 'ios_save_to_library', success: boolean, exceptionMessage: ?string, time: number, // ms uri: string, |} | {| step: 'fetch_blob', success: boolean, exceptionMessage: ?string, time: number, // ms inputURI: string, uri: string, size: ?number, mime: ?string, |} | {| step: 'data_uri_from_blob', success: boolean, exceptionMessage: ?string, time: number, // ms first255Chars: ?string, |} | {| step: 'array_buffer_from_blob', success: boolean, exceptionMessage: ?string, time: number, // ms |} | {| step: 'mime_check', success: boolean, exceptionMessage: ?string, time: number, // ms mime: ?string, |} | {| step: 'write_file', success: boolean, exceptionMessage: ?string, time: number, // ms path: string, length: number, |} | FetchFileHashMediaMissionStep | CopyFileMediaMissionStep | GetOrientationMediaMissionStep | {| step: 'preload_image', success: boolean, exceptionMessage: ?string, time: number, // ms uri: string, dimensions: ?Dimensions, |} | {| step: 'reorient_image', success: boolean, exceptionMessage: ?string, time: number, // ms uri: ?string, |} | {| step: 'upload', success: boolean, exceptionMessage: ?string, time: number, // ms inputFilename: string, outputMediaType: ?MediaType, outputURI: ?string, outputDimensions: ?Dimensions, outputLoop: ?boolean, hasWiFi?: boolean, |} | {| step: 'wait_for_capture_uri_unload', success: boolean, time: number, // ms uri: string, |}; export type MediaMissionFailure = | {| success: false, reason: 'no_file_path', |} | {| success: false, reason: 'file_stat_failed', uri: string, |} | {| success: false, reason: 'photo_manipulation_failed', size: number, // in bytes |} | {| success: false, reason: 'media_type_fetch_failed', detectedMIME: ?string, |} | {| success: false, reason: 'mime_type_mismatch', reportedMediaType: MediaType, reportedMIME: string, detectedMIME: string, |} | {| success: false, reason: 'http_upload_failed', exceptionMessage: ?string, |} | {| success: false, reason: 'video_too_long', duration: number, // in seconds |} | {| success: false, reason: 'video_probe_failed', |} | {| success: false, reason: 'video_transcode_failed', |} | {| success: false, reason: 'processing_exception', time: number, // ms exceptionMessage: ?string, |} | {| success: false, reason: 'save_unsupported', |} | {| success: false, reason: 'missing_permission', |} | {| success: false, reason: 'make_directory_failed', |} | {| success: false, reason: 'resolve_failed', uri: string, |} | {| success: false, reason: 'save_to_library_failed', uri: string, |} | {| success: false, reason: 'fetch_failed', |} | {| success: false, reason: 'data_uri_failed', |} | {| success: false, reason: 'array_buffer_failed', |} | {| success: false, reason: 'mime_check_failed', mime: ?string, |} | {| success: false, reason: 'write_file_failed', |} | {| success: false, reason: 'fetch_file_hash_failed', |} | {| success: false, reason: 'copy_file_failed', |} | {| success: false, reason: 'exif_fetch_failed', |} | {| success: false, reason: 'reorient_image_failed', |} | {| success: false, reason: 'web_sibling_validation_failed', |}; export type MediaMissionResult = MediaMissionFailure | {| success: true |}; export type MediaMission = {| steps: $ReadOnlyArray, result: MediaMissionResult, userTime: number, totalTime: number, |}; export const mediaMissionStepPropType = PropTypes.oneOfType([ photoLibrarySelectionPropType, videoLibrarySelectionPropType, PropTypes.shape({ step: PropTypes.oneOf(['photo_capture']).isRequired, time: PropTypes.number.isRequired, dimensions: dimensionsPropType.isRequired, filename: PropTypes.string.isRequired, uri: PropTypes.string.isRequired, captureTime: PropTypes.number.isRequired, selectTime: PropTypes.number.isRequired, sendTime: PropTypes.number.isRequired, retries: PropTypes.number.isRequired, }), PropTypes.shape({ step: PropTypes.oneOf(['web_selection']).isRequired, filename: PropTypes.string.isRequired, size: PropTypes.number.isRequired, mime: PropTypes.string.isRequired, selectTime: PropTypes.number.isRequired, }), PropTypes.shape({ step: PropTypes.oneOf(['asset_info_fetch']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, localURI: PropTypes.string, orientation: PropTypes.number, }), PropTypes.shape({ step: PropTypes.oneOf(['stat_file']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, uri: PropTypes.string.isRequired, fileSize: PropTypes.number, }), PropTypes.shape({ step: PropTypes.oneOf(['read_file_header']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, uri: PropTypes.string.isRequired, mime: PropTypes.string, mediaType: mediaTypePropType, }), PropTypes.shape({ step: PropTypes.oneOf(['determine_file_type']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, inputFilename: PropTypes.string.isRequired, outputMIME: PropTypes.string, outputMediaType: mediaTypePropType, outputFilename: PropTypes.string, }), PropTypes.shape({ step: PropTypes.oneOf(['frame_count']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, path: PropTypes.string.isRequired, mime: PropTypes.string.isRequired, hasMultipleFrames: PropTypes.bool, }), PropTypes.shape({ step: PropTypes.oneOf(['photo_manipulation']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, manipulation: PropTypes.object.isRequired, newMIME: PropTypes.string, newDimensions: dimensionsPropType, newURI: PropTypes.string, }), PropTypes.shape({ step: PropTypes.oneOf(['video_probe']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, path: PropTypes.string.isRequired, validFormat: PropTypes.bool.isRequired, duration: PropTypes.number, codec: PropTypes.string, format: PropTypes.arrayOf(PropTypes.string), dimensions: dimensionsPropType, }), PropTypes.shape({ step: PropTypes.oneOf(['video_ffmpeg_transcode']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, returnCode: PropTypes.number, newPath: PropTypes.string, stats: PropTypes.object, }), PropTypes.shape({ step: PropTypes.oneOf(['dispose_temporary_file']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, path: PropTypes.string.isRequired, }), PropTypes.shape({ step: PropTypes.oneOf(['save_media']).isRequired, uri: PropTypes.string.isRequired, time: PropTypes.number.isRequired, }), PropTypes.shape({ step: PropTypes.oneOf(['permissions_check']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, platform: platformPropType.isRequired, permissions: PropTypes.arrayOf(PropTypes.string).isRequired, }), PropTypes.shape({ step: PropTypes.oneOf(['make_directory']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, path: PropTypes.string.isRequired, }), PropTypes.shape({ step: PropTypes.oneOf(['android_scan_file']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, path: PropTypes.string.isRequired, }), PropTypes.shape({ step: PropTypes.oneOf(['fetch_file_hash']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, path: PropTypes.string.isRequired, hash: PropTypes.string, }), PropTypes.shape({ step: PropTypes.oneOf(['copy_file']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, source: PropTypes.string.isRequired, destination: PropTypes.string.isRequired, }), PropTypes.shape({ step: PropTypes.oneOf(['ios_save_to_library']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, uri: PropTypes.string.isRequired, }), PropTypes.shape({ step: PropTypes.oneOf(['fetch_blob']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, inputURI: PropTypes.string.isRequired, uri: PropTypes.string.isRequired, size: PropTypes.number, mime: PropTypes.string, }), PropTypes.shape({ step: PropTypes.oneOf(['data_uri_from_blob']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, first255Chars: PropTypes.string, }), PropTypes.shape({ step: PropTypes.oneOf(['array_buffer_from_blob']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, }), PropTypes.shape({ step: PropTypes.oneOf(['mime_check']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, mime: PropTypes.string, }), PropTypes.shape({ step: PropTypes.oneOf(['write_file']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, path: PropTypes.string.isRequired, length: PropTypes.number.isRequired, }), PropTypes.shape({ step: PropTypes.oneOf(['exif_fetch']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, orientation: PropTypes.number, }), PropTypes.shape({ step: PropTypes.oneOf(['preload_image']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, uri: PropTypes.string.isRequired, dimensions: dimensionsPropType, }), PropTypes.shape({ step: PropTypes.oneOf(['reorient_image']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, uri: PropTypes.string, }), PropTypes.shape({ step: PropTypes.oneOf(['upload']).isRequired, success: PropTypes.bool.isRequired, exceptionMessage: PropTypes.string, time: PropTypes.number.isRequired, inputFilename: PropTypes.string.isRequired, outputMediaType: mediaTypePropType, outputURI: PropTypes.string, outputDimensions: dimensionsPropType, outputLoop: PropTypes.bool, hasWiFi: PropTypes.bool, }), PropTypes.shape({ step: PropTypes.oneOf(['wait_for_capture_uri_unload']).isRequired, success: PropTypes.bool.isRequired, time: PropTypes.number.isRequired, uri: PropTypes.string.isRequired, }), ]); export const mediaMissionPropType = PropTypes.shape({ steps: PropTypes.arrayOf(mediaMissionStepPropType).isRequired, result: PropTypes.oneOfType([ PropTypes.shape({ success: PropTypes.oneOf([true]).isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['no_file_path']).isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['file_stat_failed']).isRequired, uri: PropTypes.string.isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['photo_manipulation_failed']).isRequired, size: PropTypes.number.isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['media_type_fetch_failed']).isRequired, detectedMIME: PropTypes.string, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['mime_type_mismatch']).isRequired, reportedMediaType: mediaTypePropType.isRequired, reportedMIME: PropTypes.string.isRequired, detectedMIME: PropTypes.string.isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['http_upload_failed']).isRequired, exceptionMessage: PropTypes.string, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['video_too_long']).isRequired, duration: PropTypes.number.isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['video_probe_failed']).isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['video_transcode_failed']).isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['processing_exception']).isRequired, time: PropTypes.number.isRequired, exceptionMessage: PropTypes.string, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['save_unsupported']).isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['missing_permission']).isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['make_directory_failed']).isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['resolve_failed']).isRequired, uri: PropTypes.string.isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['save_to_library_failed']).isRequired, uri: PropTypes.string.isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['fetch_failed']).isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['data_uri_failed']).isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['array_buffer_failed']).isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['mime_check_failed']).isRequired, mime: PropTypes.string, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['write_file_failed']).isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['fetch_file_hash_failed']).isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['copy_file_failed']).isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['exif_fetch_failed']).isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['reorient_image_failed']).isRequired, }), PropTypes.shape({ success: PropTypes.oneOf([false]).isRequired, reason: PropTypes.oneOf(['web_sibling_validation_failed']).isRequired, }), ]), userTime: PropTypes.number.isRequired, totalTime: PropTypes.number.isRequired, }); diff --git a/lib/types/message-types.js b/lib/types/message-types.js index 248e09b4f..191728510 100644 --- a/lib/types/message-types.js +++ b/lib/types/message-types.js @@ -1,821 +1,821 @@ // @flow +import invariant from 'invariant'; +import PropTypes from 'prop-types'; + +import { type Media, type Image, mediaPropType } from './media-types'; import { type ThreadInfo, threadInfoPropType, type ThreadType, threadTypePropType, } from './thread-types'; import { type RelativeUserInfo, relativeUserInfoPropType, type UserInfos, } from './user-types'; -import { type Media, type Image, mediaPropType } from './media-types'; - -import invariant from 'invariant'; -import PropTypes from 'prop-types'; export const messageTypes = Object.freeze({ TEXT: 0, CREATE_THREAD: 1, ADD_MEMBERS: 2, CREATE_SUB_THREAD: 3, CHANGE_SETTINGS: 4, REMOVE_MEMBERS: 5, CHANGE_ROLE: 6, LEAVE_THREAD: 7, JOIN_THREAD: 8, CREATE_ENTRY: 9, EDIT_ENTRY: 10, DELETE_ENTRY: 11, RESTORE_ENTRY: 12, // When the server has a message to deliver that the client can't properly // render because the client is too old, the server will send this message // type instead. Consequently, there is no MessageData for UNSUPPORTED - just // a RawMessageInfo and a MessageInfo. Note that native/persist.js handles // converting these MessageInfos when the client is upgraded. UNSUPPORTED: 13, IMAGES: 14, MULTIMEDIA: 15, UPDATE_RELATIONSHIP: 16, }); export type MessageType = $Values; export function assertMessageType(ourMessageType: number): MessageType { invariant( ourMessageType === 0 || ourMessageType === 1 || ourMessageType === 2 || ourMessageType === 3 || ourMessageType === 4 || ourMessageType === 5 || ourMessageType === 6 || ourMessageType === 7 || ourMessageType === 8 || ourMessageType === 9 || ourMessageType === 10 || ourMessageType === 11 || ourMessageType === 12 || ourMessageType === 13 || ourMessageType === 14 || ourMessageType === 15 || ourMessageType === 16, 'number is not MessageType enum', ); return ourMessageType; } const composableMessageTypes = new Set([ messageTypes.TEXT, messageTypes.IMAGES, messageTypes.MULTIMEDIA, ]); export function isComposableMessageType(ourMessageType: MessageType): boolean { return composableMessageTypes.has(ourMessageType); } export function assertComposableMessageType( ourMessageType: MessageType, ): MessageType { invariant( isComposableMessageType(ourMessageType), 'MessageType is not composed', ); return ourMessageType; } export function messageDataLocalID(messageData: MessageData) { if ( messageData.type !== messageTypes.TEXT && messageData.type !== messageTypes.IMAGES && messageData.type !== messageTypes.MULTIMEDIA ) { return null; } return messageData.localID; } const mediaMessageTypes = new Set([ messageTypes.IMAGES, messageTypes.MULTIMEDIA, ]); export function isMediaMessageType(ourMessageType: MessageType): boolean { return mediaMessageTypes.has(ourMessageType); } export function assetMediaMessageType( ourMessageType: MessageType, ): MessageType { invariant(isMediaMessageType(ourMessageType), 'MessageType is not media'); return ourMessageType; } // *MessageData = passed to createMessages function to insert into database // Raw*MessageInfo = used by server, and contained in client's local store // *MessageInfo = used by client in UI code export type TextMessageData = {| type: 0, localID?: string, // for optimistic creations. included by new clients threadID: string, creatorID: string, time: number, text: string, |}; type CreateThreadMessageData = {| type: 1, threadID: string, creatorID: string, time: number, initialThreadState: {| type: ThreadType, name: ?string, parentThreadID: ?string, color: string, memberIDs: string[], |}, |}; type AddMembersMessageData = {| type: 2, threadID: string, creatorID: string, time: number, addedUserIDs: string[], |}; type CreateSubthreadMessageData = {| type: 3, threadID: string, creatorID: string, time: number, childThreadID: string, |}; type ChangeSettingsMessageData = {| type: 4, threadID: string, creatorID: string, time: number, field: string, value: string | number, |}; type RemoveMembersMessageData = {| type: 5, threadID: string, creatorID: string, time: number, removedUserIDs: string[], |}; type ChangeRoleMessageData = {| type: 6, threadID: string, creatorID: string, time: number, userIDs: string[], newRole: string, |}; type LeaveThreadMessageData = {| type: 7, threadID: string, creatorID: string, time: number, |}; type JoinThreadMessageData = {| type: 8, threadID: string, creatorID: string, time: number, |}; type CreateEntryMessageData = {| type: 9, threadID: string, creatorID: string, time: number, entryID: string, date: string, text: string, |}; type EditEntryMessageData = {| type: 10, threadID: string, creatorID: string, time: number, entryID: string, date: string, text: string, |}; type DeleteEntryMessageData = {| type: 11, threadID: string, creatorID: string, time: number, entryID: string, date: string, text: string, |}; type RestoreEntryMessageData = {| type: 12, threadID: string, creatorID: string, time: number, entryID: string, date: string, text: string, |}; export type ImagesMessageData = {| type: 14, localID?: string, // for optimistic creations. included by new clients threadID: string, creatorID: string, time: number, media: $ReadOnlyArray, |}; export type MediaMessageData = {| type: 15, localID?: string, // for optimistic creations. included by new clients threadID: string, creatorID: string, time: number, media: $ReadOnlyArray, |}; export type UpdateRelationshipMessageData = {| +type: 16, +threadID: string, +creatorID: string, +targetID: string, +time: number, +operation: 'request_sent' | 'request_accepted', |}; export type MessageData = | TextMessageData | CreateThreadMessageData | AddMembersMessageData | CreateSubthreadMessageData | ChangeSettingsMessageData | RemoveMembersMessageData | ChangeRoleMessageData | LeaveThreadMessageData | JoinThreadMessageData | CreateEntryMessageData | EditEntryMessageData | DeleteEntryMessageData | RestoreEntryMessageData | ImagesMessageData | MediaMessageData | UpdateRelationshipMessageData; export type MultimediaMessageData = ImagesMessageData | MediaMessageData; export type RawTextMessageInfo = {| ...TextMessageData, id?: string, // null if local copy without ID yet |}; export type RawImagesMessageInfo = {| ...ImagesMessageData, id?: string, // null if local copy without ID yet |}; export type RawMediaMessageInfo = {| ...MediaMessageData, id?: string, // null if local copy without ID yet |}; export type RawMultimediaMessageInfo = | RawImagesMessageInfo | RawMediaMessageInfo; export type RawComposableMessageInfo = | RawTextMessageInfo | RawMultimediaMessageInfo; type RawRobotextMessageInfo = | {| ...CreateThreadMessageData, id: string, |} | {| ...AddMembersMessageData, id: string, |} | {| ...CreateSubthreadMessageData, id: string, |} | {| ...ChangeSettingsMessageData, id: string, |} | {| ...RemoveMembersMessageData, id: string, |} | {| ...ChangeRoleMessageData, id: string, |} | {| ...LeaveThreadMessageData, id: string, |} | {| ...JoinThreadMessageData, id: string, |} | {| ...CreateEntryMessageData, id: string, |} | {| ...EditEntryMessageData, id: string, |} | {| ...DeleteEntryMessageData, id: string, |} | {| ...RestoreEntryMessageData, id: string, |} | {| ...UpdateRelationshipMessageData, id: string, |} | {| type: 13, id: string, threadID: string, creatorID: string, time: number, robotext: string, unsupportedMessageInfo: Object, |}; export type RawMessageInfo = RawComposableMessageInfo | RawRobotextMessageInfo; export type LocallyComposedMessageInfo = { localID: string, threadID: string, ... }; export type TextMessageInfo = {| type: 0, id?: string, // null if local copy without ID yet localID?: string, // for optimistic creations threadID: string, creator: RelativeUserInfo, time: number, // millisecond timestamp text: string, |}; export type ImagesMessageInfo = {| type: 14, id?: string, // null if local copy without ID yet localID?: string, // for optimistic creations threadID: string, creator: RelativeUserInfo, time: number, // millisecond timestamp media: $ReadOnlyArray, |}; export type MediaMessageInfo = {| type: 15, id?: string, // null if local copy without ID yet localID?: string, // for optimistic creations threadID: string, creator: RelativeUserInfo, time: number, // millisecond timestamp media: $ReadOnlyArray, |}; export type MultimediaMessageInfo = ImagesMessageInfo | MediaMessageInfo; export type ComposableMessageInfo = TextMessageInfo | MultimediaMessageInfo; export type RobotextMessageInfo = | {| type: 1, id: string, threadID: string, creator: RelativeUserInfo, time: number, initialThreadState: {| type: ThreadType, name: ?string, parentThreadInfo: ?ThreadInfo, color: string, otherMembers: RelativeUserInfo[], |}, |} | {| type: 2, id: string, threadID: string, creator: RelativeUserInfo, time: number, addedMembers: RelativeUserInfo[], |} | {| type: 3, id: string, threadID: string, creator: RelativeUserInfo, time: number, childThreadInfo: ThreadInfo, |} | {| type: 4, id: string, threadID: string, creator: RelativeUserInfo, time: number, field: string, value: string | number, |} | {| type: 5, id: string, threadID: string, creator: RelativeUserInfo, time: number, removedMembers: RelativeUserInfo[], |} | {| type: 6, id: string, threadID: string, creator: RelativeUserInfo, time: number, members: RelativeUserInfo[], newRole: string, |} | {| type: 7, id: string, threadID: string, creator: RelativeUserInfo, time: number, |} | {| type: 8, id: string, threadID: string, creator: RelativeUserInfo, time: number, |} | {| type: 9, id: string, threadID: string, creator: RelativeUserInfo, time: number, entryID: string, date: string, text: string, |} | {| type: 10, id: string, threadID: string, creator: RelativeUserInfo, time: number, entryID: string, date: string, text: string, |} | {| type: 11, id: string, threadID: string, creator: RelativeUserInfo, time: number, entryID: string, date: string, text: string, |} | {| type: 12, id: string, threadID: string, creator: RelativeUserInfo, time: number, entryID: string, date: string, text: string, |} | {| type: 13, id: string, threadID: string, creator: RelativeUserInfo, time: number, robotext: string, unsupportedMessageInfo: Object, |} | {| +type: 16, +id: string, +threadID: string, +creator: RelativeUserInfo, +target: RelativeUserInfo, +time: number, +operation: 'request_sent' | 'request_accepted', |}; export type PreviewableMessageInfo = | RobotextMessageInfo | MultimediaMessageInfo; export type MessageInfo = ComposableMessageInfo | RobotextMessageInfo; export const messageInfoPropType = PropTypes.oneOfType([ PropTypes.shape({ type: PropTypes.oneOf([messageTypes.TEXT]).isRequired, id: PropTypes.string, localID: PropTypes.string, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, text: PropTypes.string.isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.CREATE_THREAD]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, initialThreadState: PropTypes.shape({ type: threadTypePropType.isRequired, name: PropTypes.string, parentThreadInfo: threadInfoPropType, color: PropTypes.string.isRequired, otherMembers: PropTypes.arrayOf(relativeUserInfoPropType).isRequired, }).isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.ADD_MEMBERS]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, addedMembers: PropTypes.arrayOf(relativeUserInfoPropType).isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.CREATE_SUB_THREAD]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, childThreadInfo: threadInfoPropType.isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.CHANGE_SETTINGS]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, field: PropTypes.string.isRequired, value: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.REMOVE_MEMBERS]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, removedMembers: PropTypes.arrayOf(relativeUserInfoPropType).isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.CHANGE_ROLE]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, members: PropTypes.arrayOf(relativeUserInfoPropType).isRequired, newRole: PropTypes.string.isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.LEAVE_THREAD]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.JOIN_THREAD]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.CREATE_ENTRY]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, entryID: PropTypes.string.isRequired, date: PropTypes.string.isRequired, text: PropTypes.string.isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.EDIT_ENTRY]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, entryID: PropTypes.string.isRequired, date: PropTypes.string.isRequired, text: PropTypes.string.isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.DELETE_ENTRY]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, entryID: PropTypes.string.isRequired, date: PropTypes.string.isRequired, text: PropTypes.string.isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.RESTORE_ENTRY]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, entryID: PropTypes.string.isRequired, date: PropTypes.string.isRequired, text: PropTypes.string.isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.UNSUPPORTED]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, robotext: PropTypes.string.isRequired, unsupportedMessageInfo: PropTypes.object.isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.IMAGES]).isRequired, id: PropTypes.string, localID: PropTypes.string, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, media: PropTypes.arrayOf(mediaPropType).isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([messageTypes.MULTIMEDIA]).isRequired, id: PropTypes.string, localID: PropTypes.string, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, media: PropTypes.arrayOf(mediaPropType).isRequired, }), PropTypes.exact({ type: PropTypes.oneOf([messageTypes.UPDATE_RELATIONSHIP]).isRequired, id: PropTypes.string.isRequired, threadID: PropTypes.string.isRequired, creator: relativeUserInfoPropType.isRequired, target: relativeUserInfoPropType.isRequired, time: PropTypes.number.isRequired, operation: PropTypes.oneOf(['request_sent', 'request_accepted']), }), ]); export type ThreadMessageInfo = {| messageIDs: string[], startReached: boolean, lastNavigatedTo: number, // millisecond timestamp lastPruned: number, // millisecond timestamp |}; // Tracks client-local information about a message that hasn't been assigned an // ID by the server yet. As soon as the client gets an ack from the server for // this message, it will clear the LocalMessageInfo. export type LocalMessageInfo = {| sendFailed?: boolean, |}; export const localMessageInfoPropType = PropTypes.shape({ sendFailed: PropTypes.bool, }); export type MessageStore = {| messages: { [id: string]: RawMessageInfo }, threads: { [threadID: string]: ThreadMessageInfo }, local: { [id: string]: LocalMessageInfo }, currentAsOf: number, |}; export const messageTruncationStatus = Object.freeze({ // EXHAUSTIVE means we've reached the start of the thread. Either the result // set includes the very first message for that thread, or there is nothing // behind the cursor you queried for. Given that the client only ever issues // ranged queries whose range, when unioned with what is in state, represent // the set of all messages for a given thread, we can guarantee that getting // EXHAUSTIVE means the start has been reached. EXHAUSTIVE: 'exhaustive', // TRUNCATED is rare, and means that the server can't guarantee that the // result set for a given thread is contiguous with what the client has in its // state. If the client can't verify the contiguousness itself, it needs to // replace its Redux store's contents with what it is in this payload. // 1) getMessageInfosSince: Result set for thread is equal to max, and the // truncation status isn't EXHAUSTIVE (ie. doesn't include the very first // message). // 2) getMessageInfos: ThreadSelectionCriteria does not specify cursors, the // result set for thread is equal to max, and the truncation status isn't // EXHAUSTIVE. If cursors are specified, we never return truncated, since // the cursor given us guarantees the contiguousness of the result set. // Note that in the reducer, we can guarantee contiguousness if there is any // intersection between messageIDs in the result set and the set currently in // the Redux store. TRUNCATED: 'truncated', // UNCHANGED means the result set is guaranteed to be contiguous with what the // client has in its state, but is not EXHAUSTIVE. Basically, it's anything // that isn't either EXHAUSTIVE or TRUNCATED. UNCHANGED: 'unchanged', }); export type MessageTruncationStatus = $Values; export function assertMessageTruncationStatus( ourMessageTruncationStatus: string, ): MessageTruncationStatus { invariant( ourMessageTruncationStatus === 'truncated' || ourMessageTruncationStatus === 'unchanged' || ourMessageTruncationStatus === 'exhaustive', 'string is not ourMessageTruncationStatus enum', ); return ourMessageTruncationStatus; } export type MessageTruncationStatuses = { [threadID: string]: MessageTruncationStatus, }; export type ThreadCursors = { [threadID: string]: ?string }; export type ThreadSelectionCriteria = {| threadCursors?: ?ThreadCursors, joinedThreads?: ?boolean, |}; export type FetchMessageInfosRequest = {| cursors: ThreadCursors, numberPerThread?: ?number, |}; export type FetchMessageInfosResponse = {| rawMessageInfos: RawMessageInfo[], truncationStatuses: MessageTruncationStatuses, userInfos: UserInfos, |}; export type FetchMessageInfosResult = {| rawMessageInfos: RawMessageInfo[], truncationStatuses: MessageTruncationStatuses, |}; export type FetchMessageInfosPayload = {| threadID: string, rawMessageInfos: RawMessageInfo[], truncationStatus: MessageTruncationStatus, |}; export type MessagesResponse = {| rawMessageInfos: RawMessageInfo[], truncationStatuses: MessageTruncationStatuses, currentAsOf: number, |}; export const defaultNumberPerThread = 20; export type SendMessageResponse = {| newMessageInfo: RawMessageInfo, |}; export type SendMessageResult = {| id: string, time: number, |}; export type SendMessagePayload = {| localID: string, serverID: string, threadID: string, time: number, |}; export type SendTextMessageRequest = {| threadID: string, localID?: string, text: string, |}; export type SendMultimediaMessageRequest = {| threadID: string, localID: string, mediaIDs: $ReadOnlyArray, |}; // Used for the message info included in log-in type actions export type GenericMessagesResult = {| messageInfos: RawMessageInfo[], truncationStatus: MessageTruncationStatuses, watchedIDsAtRequestTime: $ReadOnlyArray, currentAsOf: number, |}; export type SaveMessagesPayload = {| rawMessageInfos: $ReadOnlyArray, updatesCurrentAsOf: number, |}; export type NewMessagesPayload = {| messagesResult: MessagesResponse, |}; export type MessageStorePrunePayload = {| threadIDs: $ReadOnlyArray, |}; diff --git a/lib/types/redis-types.js b/lib/types/redis-types.js index a8ed0a0eb..c056aecfb 100644 --- a/lib/types/redis-types.js +++ b/lib/types/redis-types.js @@ -1,38 +1,38 @@ // @flow -import type { RawUpdateInfo } from './update-types'; import type { RawMessageInfo } from './message-types'; +import type { RawUpdateInfo } from './update-types'; // The types of messages that can be published to Redis export const redisMessageTypes = Object.freeze({ START_SUBSCRIPTION: 0, NEW_UPDATES: 1, NEW_MESSAGES: 2, }); export type RedisMessageTypes = $Values; // This message is sent to a session channel indicating that a client just // connected with this session ID. Since there should only ever be a single // session active for a given sessionID, this message tells all sessions with a // different instanceID to terminate their sockets. type StartSubscriptionRedisMessage = {| type: 0, instanceID: string, |}; export type NewUpdatesRedisMessage = {| type: 1, updates: $ReadOnlyArray, ignoreSession?: string, |}; type NewMessagesRedisMessage = {| type: 2, messages: $ReadOnlyArray, |}; export type RedisMessage = | StartSubscriptionRedisMessage | NewUpdatesRedisMessage | NewMessagesRedisMessage; export type UpdateTarget = {| userID: string, +sessionID?: string |}; export type SessionIdentifier = {| userID: string, sessionID: string |}; diff --git a/lib/types/redux-types.js b/lib/types/redux-types.js index c670a5ba7..35a866120 100644 --- a/lib/types/redux-types.js +++ b/lib/types/redux-types.js @@ -1,831 +1,831 @@ // @flow import type { - ThreadStore, - ChangeThreadSettingsPayload, - LeaveThreadPayload, - NewThreadResult, - ThreadJoinPayload, -} from './thread-types'; + LogOutResult, + LogInStartingPayload, + LogInResult, + RegisterResult, +} from './account-types'; +import type { + ActivityUpdateSuccessPayload, + QueueActivityUpdatesPayload, + SetThreadUnreadStatusPayload, +} from './activity-types'; import type { RawEntryInfo, EntryStore, CalendarQuery, SaveEntryPayload, CreateEntryPayload, DeleteEntryResponse, RestoreEntryPayload, FetchEntryInfosResult, CalendarQueryUpdateResult, CalendarQueryUpdateStartingPayload, } from './entry-types'; -import type { LoadingStatus, LoadingInfo } from './loading-types'; -import type { BaseNavInfo } from './nav-types'; -import type { CurrentUserInfo, UserStore } from './user-types'; import type { - LogOutResult, - LogInStartingPayload, - LogInResult, - RegisterResult, -} from './account-types'; -import type { UserSearchResult } from '../types/search-types'; + CalendarFilter, + CalendarThreadFilter, + SetCalendarDeletedFilterPayload, +} from './filter-types'; +import type { LoadingStatus, LoadingInfo } from './loading-types'; +import type { UpdateMultimediaMessageMediaPayload } from './media-types'; import type { MessageStore, RawTextMessageInfo, RawMultimediaMessageInfo, FetchMessageInfosPayload, SendMessagePayload, SaveMessagesPayload, NewMessagesPayload, MessageStorePrunePayload, LocallyComposedMessageInfo, } from './message-types'; -import type { SetSessionPayload } from './session-types'; -import type { ProcessServerRequestsPayload } from './request-types'; +import type { BaseNavInfo } from './nav-types'; import type { ClearDeliveredReportsPayload, ClientReportCreationRequest, QueueReportsPayload, } from './report-types'; -import type { - CalendarFilter, - CalendarThreadFilter, - SetCalendarDeletedFilterPayload, -} from './filter-types'; -import type { SubscriptionUpdateResult } from './subscription-types'; +import type { ProcessServerRequestsPayload } from './request-types'; +import type { UserSearchResult } from './search-types'; +import type { SetSessionPayload } from './session-types'; import type { ConnectionInfo, StateSyncFullActionPayload, StateSyncIncrementalActionPayload, UpdateConnectionStatusPayload, SetLateResponsePayload, UpdateDisconnectedBarPayload, } from './socket-types'; -import type { UpdatesResultWithUserInfos } from './update-types'; +import type { SubscriptionUpdateResult } from './subscription-types'; import type { - ActivityUpdateSuccessPayload, - QueueActivityUpdatesPayload, - SetThreadUnreadStatusPayload, -} from './activity-types'; -import type { UpdateMultimediaMessageMediaPayload } from './media-types'; + ThreadStore, + ChangeThreadSettingsPayload, + LeaveThreadPayload, + NewThreadResult, + ThreadJoinPayload, +} from './thread-types'; +import type { UpdatesResultWithUserInfos } from './update-types'; +import type { CurrentUserInfo, UserStore } from './user-types'; export type BaseAppState = { navInfo: NavInfo, currentUserInfo: ?CurrentUserInfo, entryStore: EntryStore, threadStore: ThreadStore, userStore: UserStore, messageStore: MessageStore, updatesCurrentAsOf: number, // millisecond timestamp loadingStatuses: { [key: string]: { [idx: number]: LoadingStatus } }, calendarFilters: $ReadOnlyArray, urlPrefix: string, connection: ConnectionInfo, watchedThreadIDs: $ReadOnlyArray, foreground: boolean, nextLocalID: number, queuedReports: $ReadOnlyArray, dataLoaded: boolean, }; // Web JS runtime doesn't have access to the cookie for security reasons. // Native JS doesn't have a sessionID because the cookieID is used instead. // Web JS doesn't have a device token because it's not a device... export type NativeAppState = BaseAppState<*> & { sessionID?: void, deviceToken: ?string, cookie: ?string, }; export type WebAppState = BaseAppState<*> & { sessionID: ?string, deviceToken?: void, cookie?: void, }; export type AppState = NativeAppState | WebAppState; export type BaseAction = | {| type: '@@redux/INIT', payload?: void, |} | {| type: 'FETCH_ENTRIES_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'FETCH_ENTRIES_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'FETCH_ENTRIES_SUCCESS', payload: FetchEntryInfosResult, loadingInfo: LoadingInfo, |} | {| type: 'LOG_OUT_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'LOG_OUT_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'LOG_OUT_SUCCESS', payload: LogOutResult, loadingInfo: LoadingInfo, |} | {| type: 'DELETE_ACCOUNT_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'DELETE_ACCOUNT_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'DELETE_ACCOUNT_SUCCESS', payload: LogOutResult, loadingInfo: LoadingInfo, |} | {| type: 'CREATE_LOCAL_ENTRY', payload: RawEntryInfo, |} | {| type: 'CREATE_ENTRY_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'CREATE_ENTRY_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'CREATE_ENTRY_SUCCESS', payload: CreateEntryPayload, loadingInfo: LoadingInfo, |} | {| type: 'SAVE_ENTRY_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'SAVE_ENTRY_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'SAVE_ENTRY_SUCCESS', payload: SaveEntryPayload, loadingInfo: LoadingInfo, |} | {| type: 'CONCURRENT_MODIFICATION_RESET', payload: {| id: string, dbText: string, |}, |} | {| type: 'DELETE_ENTRY_STARTED', loadingInfo: LoadingInfo, payload: {| localID: ?string, serverID: ?string, |}, |} | {| type: 'DELETE_ENTRY_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'DELETE_ENTRY_SUCCESS', payload: ?DeleteEntryResponse, loadingInfo: LoadingInfo, |} | {| type: 'LOG_IN_STARTED', loadingInfo: LoadingInfo, payload: LogInStartingPayload, |} | {| type: 'LOG_IN_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'LOG_IN_SUCCESS', payload: LogInResult, loadingInfo: LoadingInfo, |} | {| type: 'REGISTER_STARTED', loadingInfo: LoadingInfo, payload: LogInStartingPayload, |} | {| type: 'REGISTER_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'REGISTER_SUCCESS', payload: RegisterResult, loadingInfo: LoadingInfo, |} | {| type: 'RESET_PASSWORD_STARTED', payload: {| calendarQuery: CalendarQuery |}, loadingInfo: LoadingInfo, |} | {| type: 'RESET_PASSWORD_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'RESET_PASSWORD_SUCCESS', payload: LogInResult, loadingInfo: LoadingInfo, |} | {| type: 'FORGOT_PASSWORD_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'FORGOT_PASSWORD_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'FORGOT_PASSWORD_SUCCESS', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'CHANGE_USER_SETTINGS_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'CHANGE_USER_SETTINGS_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'CHANGE_USER_SETTINGS_SUCCESS', payload: {| email: string, |}, loadingInfo: LoadingInfo, |} | {| type: 'RESEND_VERIFICATION_EMAIL_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'RESEND_VERIFICATION_EMAIL_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'RESEND_VERIFICATION_EMAIL_SUCCESS', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'CHANGE_THREAD_SETTINGS_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'CHANGE_THREAD_SETTINGS_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'CHANGE_THREAD_SETTINGS_SUCCESS', payload: ChangeThreadSettingsPayload, loadingInfo: LoadingInfo, |} | {| type: 'DELETE_THREAD_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'DELETE_THREAD_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'DELETE_THREAD_SUCCESS', payload: LeaveThreadPayload, loadingInfo: LoadingInfo, |} | {| type: 'NEW_THREAD_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'NEW_THREAD_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'NEW_THREAD_SUCCESS', payload: NewThreadResult, loadingInfo: LoadingInfo, |} | {| type: 'REMOVE_USERS_FROM_THREAD_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'REMOVE_USERS_FROM_THREAD_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'REMOVE_USERS_FROM_THREAD_SUCCESS', payload: ChangeThreadSettingsPayload, loadingInfo: LoadingInfo, |} | {| type: 'CHANGE_THREAD_MEMBER_ROLES_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'CHANGE_THREAD_MEMBER_ROLES_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'CHANGE_THREAD_MEMBER_ROLES_SUCCESS', payload: ChangeThreadSettingsPayload, loadingInfo: LoadingInfo, |} | {| type: 'FETCH_REVISIONS_FOR_ENTRY_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'FETCH_REVISIONS_FOR_ENTRY_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'FETCH_REVISIONS_FOR_ENTRY_SUCCESS', payload: {| entryID: string, text: string, deleted: boolean, |}, loadingInfo: LoadingInfo, |} | {| type: 'RESTORE_ENTRY_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'RESTORE_ENTRY_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'RESTORE_ENTRY_SUCCESS', payload: RestoreEntryPayload, loadingInfo: LoadingInfo, |} | {| type: 'JOIN_THREAD_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'JOIN_THREAD_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'JOIN_THREAD_SUCCESS', payload: ThreadJoinPayload, loadingInfo: LoadingInfo, |} | {| type: 'LEAVE_THREAD_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'LEAVE_THREAD_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'LEAVE_THREAD_SUCCESS', payload: LeaveThreadPayload, loadingInfo: LoadingInfo, |} | {| type: 'SET_NEW_SESSION', payload: SetSessionPayload, |} | {| type: 'persist/REHYDRATE', payload: ?BaseAppState<*>, |} | {| type: 'FETCH_MESSAGES_BEFORE_CURSOR_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'FETCH_MESSAGES_BEFORE_CURSOR_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'FETCH_MESSAGES_BEFORE_CURSOR_SUCCESS', payload: FetchMessageInfosPayload, loadingInfo: LoadingInfo, |} | {| type: 'FETCH_MOST_RECENT_MESSAGES_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'FETCH_MOST_RECENT_MESSAGES_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'FETCH_MOST_RECENT_MESSAGES_SUCCESS', payload: FetchMessageInfosPayload, loadingInfo: LoadingInfo, |} | {| type: 'SEND_TEXT_MESSAGE_STARTED', loadingInfo: LoadingInfo, payload: RawTextMessageInfo, |} | {| type: 'SEND_TEXT_MESSAGE_FAILED', error: true, payload: Error & { localID: string, threadID: string, }, loadingInfo: LoadingInfo, |} | {| type: 'SEND_TEXT_MESSAGE_SUCCESS', payload: SendMessagePayload, loadingInfo: LoadingInfo, |} | {| type: 'SEND_MULTIMEDIA_MESSAGE_STARTED', loadingInfo: LoadingInfo, payload: RawMultimediaMessageInfo, |} | {| type: 'SEND_MULTIMEDIA_MESSAGE_FAILED', error: true, payload: Error & { localID: string, threadID: string, }, loadingInfo: LoadingInfo, |} | {| type: 'SEND_MULTIMEDIA_MESSAGE_SUCCESS', payload: SendMessagePayload, loadingInfo: LoadingInfo, |} | {| type: 'SEARCH_USERS_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'SEARCH_USERS_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'SEARCH_USERS_SUCCESS', payload: UserSearchResult, loadingInfo: LoadingInfo, |} | {| type: 'SAVE_DRAFT', payload: { key: string, draft: string, }, |} | {| type: 'UPDATE_ACTIVITY_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'UPDATE_ACTIVITY_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'UPDATE_ACTIVITY_SUCCESS', payload: ActivityUpdateSuccessPayload, loadingInfo: LoadingInfo, |} | {| type: 'SET_DEVICE_TOKEN_STARTED', payload: string, loadingInfo: LoadingInfo, |} | {| type: 'SET_DEVICE_TOKEN_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'SET_DEVICE_TOKEN_SUCCESS', payload: string, loadingInfo: LoadingInfo, |} | {| type: 'HANDLE_VERIFICATION_CODE_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'HANDLE_VERIFICATION_CODE_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'HANDLE_VERIFICATION_CODE_SUCCESS', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'SEND_REPORT_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'SEND_REPORT_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'SEND_REPORT_SUCCESS', payload?: ClearDeliveredReportsPayload, loadingInfo: LoadingInfo, |} | {| type: 'SEND_REPORTS_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'SEND_REPORTS_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'SEND_REPORTS_SUCCESS', payload?: ClearDeliveredReportsPayload, loadingInfo: LoadingInfo, |} | {| type: 'QUEUE_REPORTS', payload: QueueReportsPayload, |} | {| type: 'SET_URL_PREFIX', payload: string, |} | {| type: 'SAVE_MESSAGES', payload: SaveMessagesPayload, |} | {| type: 'UPDATE_CALENDAR_THREAD_FILTER', payload: CalendarThreadFilter, |} | {| type: 'CLEAR_CALENDAR_THREAD_FILTER', payload?: void, |} | {| type: 'SET_CALENDAR_DELETED_FILTER', payload: SetCalendarDeletedFilterPayload, |} | {| type: 'UPDATE_SUBSCRIPTION_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'UPDATE_SUBSCRIPTION_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'UPDATE_SUBSCRIPTION_SUCCESS', payload: SubscriptionUpdateResult, loadingInfo: LoadingInfo, |} | {| type: 'UPDATE_CALENDAR_QUERY_STARTED', loadingInfo: LoadingInfo, payload?: CalendarQueryUpdateStartingPayload, |} | {| type: 'UPDATE_CALENDAR_QUERY_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'UPDATE_CALENDAR_QUERY_SUCCESS', payload: CalendarQueryUpdateResult, loadingInfo: LoadingInfo, |} | {| type: 'FULL_STATE_SYNC', payload: StateSyncFullActionPayload, |} | {| type: 'INCREMENTAL_STATE_SYNC', payload: StateSyncIncrementalActionPayload, |} | {| type: 'PROCESS_SERVER_REQUESTS', payload: ProcessServerRequestsPayload, |} | {| type: 'UPDATE_CONNECTION_STATUS', payload: UpdateConnectionStatusPayload, |} | {| type: 'QUEUE_ACTIVITY_UPDATES', payload: QueueActivityUpdatesPayload, |} | {| type: 'FOREGROUND', payload?: void, |} | {| type: 'BACKGROUND', payload?: void, |} | {| type: 'UNSUPERVISED_BACKGROUND', payload?: void, |} | {| type: 'PROCESS_UPDATES', payload: UpdatesResultWithUserInfos, |} | {| type: 'PROCESS_MESSAGES', payload: NewMessagesPayload, |} | {| type: 'MESSAGE_STORE_PRUNE', payload: MessageStorePrunePayload, |} | {| type: 'SET_LATE_RESPONSE', payload: SetLateResponsePayload, |} | {| type: 'UPDATE_DISCONNECTED_BAR', payload: UpdateDisconnectedBarPayload, |} | {| type: 'REQUEST_ACCESS_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'REQUEST_ACCESS_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'REQUEST_ACCESS_SUCCESS', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'UPDATE_MULTIMEDIA_MESSAGE_MEDIA', payload: UpdateMultimediaMessageMediaPayload, |} | {| type: 'CREATE_LOCAL_MESSAGE', payload: LocallyComposedMessageInfo, |} | {| type: 'UPDATE_RELATIONSHIPS_STARTED', payload?: void, loadingInfo: LoadingInfo, |} | {| type: 'UPDATE_RELATIONSHIPS_FAILED', error: true, payload: Error, loadingInfo: LoadingInfo, |} | {| type: 'UPDATE_RELATIONSHIPS_SUCCESS', payload?: void, loadingInfo: LoadingInfo, |} | {| +type: 'SET_THREAD_UNREAD_STATUS_STARTED', +payload: {| +threadID: string, +unread: boolean, |}, +loadingInfo: LoadingInfo, |} | {| +type: 'SET_THREAD_UNREAD_STATUS_FAILED', +error: true, +payload: Error, +loadingInfo: LoadingInfo, |} | {| +type: 'SET_THREAD_UNREAD_STATUS_SUCCESS', +payload: SetThreadUnreadStatusPayload, |}; export type ActionPayload = ?(Object | Array<*> | $ReadOnlyArray<*> | string); export type SuperAction = { type: string, payload?: ActionPayload, loadingInfo?: LoadingInfo, error?: boolean, }; type ThunkedAction = (dispatch: Dispatch) => void; export type PromisedAction = (dispatch: Dispatch) => Promise; export type Dispatch = ((promisedAction: PromisedAction) => Promise) & ((thunkedAction: ThunkedAction) => void) & ((action: SuperAction) => boolean); // This is lifted from redux-persist/lib/constants.js // I don't want to add redux-persist to the web/server bundles... // import { REHYDRATE } from 'redux-persist'; export const rehydrateActionType = 'persist/REHYDRATE'; diff --git a/lib/types/relationship-types.js b/lib/types/relationship-types.js index 4e555821d..34e90acf0 100644 --- a/lib/types/relationship-types.js +++ b/lib/types/relationship-types.js @@ -1,67 +1,67 @@ // @flow -import type { AccountUserInfo } from './user-types'; - import { values } from '../utils/objects'; +import type { AccountUserInfo } from './user-types'; + export const undirectedStatus = Object.freeze({ KNOW_OF: 0, FRIEND: 2, }); export type UndirectedStatus = $Values; export const directedStatus = Object.freeze({ PENDING_FRIEND: 1, BLOCKED: 3, }); export type DirectedStatus = $Values; export const userRelationshipStatus = Object.freeze({ REQUEST_SENT: 1, REQUEST_RECEIVED: 2, FRIEND: 3, BLOCKED_BY_VIEWER: 4, BLOCKED_VIEWER: 5, BOTH_BLOCKED: 6, }); export type UserRelationshipStatus = $Values; export const relationshipActions = Object.freeze({ FRIEND: 'friend', UNFRIEND: 'unfriend', BLOCK: 'block', UNBLOCK: 'unblock', }); export type RelationshipAction = $Values; export const relationshipActionsList: $ReadOnlyArray = values( relationshipActions, ); export type RelationshipRequest = {| action: RelationshipAction, userIDs: $ReadOnlyArray, |}; type SharedRelationshipRow = {| user1: string, user2: string, |}; export type DirectedRelationshipRow = {| ...SharedRelationshipRow, status: DirectedStatus, |}; export type UndirectedRelationshipRow = {| ...SharedRelationshipRow, status: UndirectedStatus, |}; export type RelationshipErrors = $Shape<{| invalid_user: string[], already_friends: string[], user_blocked: string[], |}>; export type UserRelationships = {| +friends: $ReadOnlyArray, +blocked: $ReadOnlyArray, |}; diff --git a/lib/types/report-types.js b/lib/types/report-types.js index 8c0a4e0ef..e00d0dd09 100644 --- a/lib/types/report-types.js +++ b/lib/types/report-types.js @@ -1,233 +1,233 @@ // @flow -import type { AppState, BaseAction } from './redux-types'; -import type { UserInfo, UserInfos } from './user-types'; +import invariant from 'invariant'; +import PropTypes from 'prop-types'; + import { type PlatformDetails, platformDetailsPropType } from './device-types'; -import { type RawThreadInfo, rawThreadInfoPropType } from './thread-types'; import { type RawEntryInfo, type CalendarQuery, rawEntryInfoPropType, calendarQueryPropType, } from './entry-types'; import { type MediaMission, mediaMissionPropType } from './media-types'; - -import invariant from 'invariant'; -import PropTypes from 'prop-types'; +import type { AppState, BaseAction } from './redux-types'; +import { type RawThreadInfo, rawThreadInfoPropType } from './thread-types'; +import type { UserInfo, UserInfos } from './user-types'; export const reportTypes = Object.freeze({ ERROR: 0, THREAD_INCONSISTENCY: 1, ENTRY_INCONSISTENCY: 2, MEDIA_MISSION: 3, USER_INCONSISTENCY: 4, }); type ReportType = $Values; export function assertReportType(reportType: number): ReportType { invariant( reportType === 0 || reportType === 1 || reportType === 2 || reportType === 3 || reportType === 4, 'number is not ReportType enum', ); return reportType; } export type ErrorInfo = { componentStack: string }; export type ErrorData = {| error: Error, info?: ErrorInfo |}; export type FlatErrorData = {| errorMessage: string, stack?: string, componentStack?: ?string, |}; export type ActionSummary = {| type: $PropertyType, time: number, summary: string, |}; export type ThreadInconsistencyReportShape = {| platformDetails: PlatformDetails, beforeAction: { [id: string]: RawThreadInfo }, action: BaseAction, pollResult?: { [id: string]: RawThreadInfo }, pushResult: { [id: string]: RawThreadInfo }, lastActionTypes?: $ReadOnlyArray<$PropertyType>, lastActions?: $ReadOnlyArray, time?: number, |}; export type EntryInconsistencyReportShape = {| platformDetails: PlatformDetails, beforeAction: { [id: string]: RawEntryInfo }, action: BaseAction, calendarQuery: CalendarQuery, pollResult?: { [id: string]: RawEntryInfo }, pushResult: { [id: string]: RawEntryInfo }, lastActionTypes?: $ReadOnlyArray<$PropertyType>, lastActions?: $ReadOnlyArray, time: number, |}; export type UserInconsistencyReportShape = {| platformDetails: PlatformDetails, action: BaseAction, beforeStateCheck: UserInfos, afterStateCheck: UserInfos, lastActions: $ReadOnlyArray, time: number, |}; type ErrorReportCreationRequest = {| type: 0, platformDetails: PlatformDetails, errors: $ReadOnlyArray, preloadedState: AppState, currentState: AppState, actions: $ReadOnlyArray, |}; export type ThreadInconsistencyReportCreationRequest = {| ...ThreadInconsistencyReportShape, type: 1, |}; export type EntryInconsistencyReportCreationRequest = {| ...EntryInconsistencyReportShape, type: 2, |}; export type MediaMissionReportCreationRequest = {| type: 3, platformDetails: PlatformDetails, time: number, // ms mediaMission: MediaMission, uploadServerID?: ?string, uploadLocalID?: ?string, mediaLocalID?: ?string, // deprecated messageServerID?: ?string, messageLocalID?: ?string, |}; export type UserInconsistencyReportCreationRequest = {| ...UserInconsistencyReportShape, type: 4, |}; export type ReportCreationRequest = | ErrorReportCreationRequest | ThreadInconsistencyReportCreationRequest | EntryInconsistencyReportCreationRequest | MediaMissionReportCreationRequest | UserInconsistencyReportCreationRequest; export type ClientThreadInconsistencyReportShape = {| platformDetails: PlatformDetails, beforeAction: { [id: string]: RawThreadInfo }, action: BaseAction, pushResult: { [id: string]: RawThreadInfo }, lastActions: $ReadOnlyArray, time: number, |}; export type ClientEntryInconsistencyReportShape = {| platformDetails: PlatformDetails, beforeAction: { [id: string]: RawEntryInfo }, action: BaseAction, calendarQuery: CalendarQuery, pushResult: { [id: string]: RawEntryInfo }, lastActions: $ReadOnlyArray, time: number, |}; export type ClientThreadInconsistencyReportCreationRequest = {| ...ClientThreadInconsistencyReportShape, type: 1, |}; export type ClientEntryInconsistencyReportCreationRequest = {| ...ClientEntryInconsistencyReportShape, type: 2, |}; export type ClientReportCreationRequest = | ErrorReportCreationRequest | ClientThreadInconsistencyReportCreationRequest | ClientEntryInconsistencyReportCreationRequest | MediaMissionReportCreationRequest | UserInconsistencyReportCreationRequest; export type QueueReportsPayload = {| reports: $ReadOnlyArray, |}; export type ClearDeliveredReportsPayload = {| reports: $ReadOnlyArray, |}; const actionSummaryPropType = PropTypes.shape({ type: PropTypes.string.isRequired, time: PropTypes.number.isRequired, summary: PropTypes.string.isRequired, }); export const queuedClientReportCreationRequestPropType = PropTypes.oneOfType([ PropTypes.shape({ type: PropTypes.oneOf([reportTypes.THREAD_INCONSISTENCY]).isRequired, platformDetails: platformDetailsPropType.isRequired, beforeAction: PropTypes.objectOf(rawThreadInfoPropType).isRequired, action: PropTypes.object.isRequired, pollResult: PropTypes.objectOf(rawThreadInfoPropType), pushResult: PropTypes.objectOf(rawThreadInfoPropType).isRequired, lastActions: PropTypes.arrayOf(actionSummaryPropType).isRequired, time: PropTypes.number.isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([reportTypes.ENTRY_INCONSISTENCY]).isRequired, platformDetails: platformDetailsPropType.isRequired, beforeAction: PropTypes.objectOf(rawEntryInfoPropType).isRequired, action: PropTypes.object.isRequired, calendarQuery: calendarQueryPropType.isRequired, pollResult: PropTypes.objectOf(rawEntryInfoPropType), pushResult: PropTypes.objectOf(rawEntryInfoPropType).isRequired, lastActions: PropTypes.arrayOf(actionSummaryPropType).isRequired, time: PropTypes.number.isRequired, }), PropTypes.shape({ type: PropTypes.oneOf([reportTypes.MEDIA_MISSION]).isRequired, platformDetails: platformDetailsPropType.isRequired, time: PropTypes.number.isRequired, mediaMission: mediaMissionPropType.isRequired, uploadServerID: PropTypes.string, uploadLocalID: PropTypes.string, mediaLocalID: PropTypes.string, messageServerID: PropTypes.string, messageLocalID: PropTypes.string, }), PropTypes.shape({ type: PropTypes.oneOf([reportTypes.USER_INCONSISTENCY]).isRequired, platformDetails: platformDetailsPropType.isRequired, action: PropTypes.object.isRequired, beforeStateCheck: PropTypes.objectOf(rawThreadInfoPropType).isRequired, afterStateCheck: PropTypes.objectOf(rawThreadInfoPropType).isRequired, lastActions: PropTypes.arrayOf(actionSummaryPropType).isRequired, time: PropTypes.number.isRequired, }), ]); export type ReportCreationResponse = {| id: string, |}; type ReportInfo = {| id: string, viewerID: string, platformDetails: PlatformDetails, creationTime: number, |}; export type FetchErrorReportInfosRequest = {| cursor: ?string, |}; export type FetchErrorReportInfosResponse = {| reports: $ReadOnlyArray, userInfos: $ReadOnlyArray, |}; export type ReduxToolsImport = {| preloadedState: AppState, payload: $ReadOnlyArray, |}; diff --git a/lib/types/request-types.js b/lib/types/request-types.js index 49513aa22..2572c3792 100644 --- a/lib/types/request-types.js +++ b/lib/types/request-types.js @@ -1,138 +1,138 @@ // @flow +import invariant from 'invariant'; + +import { type ActivityUpdate } from './activity-types'; import type { Platform, PlatformDetails } from './device-types'; -import type { RawThreadInfo } from './thread-types'; import type { RawEntryInfo, CalendarQuery } from './entry-types'; -import { type ActivityUpdate } from './activity-types'; -import type { CurrentUserInfo, AccountUserInfo } from './user-types'; import type { ThreadInconsistencyReportShape, EntryInconsistencyReportShape, ClientThreadInconsistencyReportShape, ClientEntryInconsistencyReportShape, } from './report-types'; - -import invariant from 'invariant'; +import type { RawThreadInfo } from './thread-types'; +import type { CurrentUserInfo, AccountUserInfo } from './user-types'; // "Server requests" are requests for information that the server delivers to // clients. Clients then respond to those requests with a "client response". export const serverRequestTypes = Object.freeze({ PLATFORM: 0, //DEVICE_TOKEN: 1, (DEPRECATED) THREAD_INCONSISTENCY: 2, PLATFORM_DETAILS: 3, //INITIAL_ACTIVITY_UPDATE: 4, (DEPRECATED) ENTRY_INCONSISTENCY: 5, CHECK_STATE: 6, INITIAL_ACTIVITY_UPDATES: 7, }); type ServerRequestType = $Values; export function assertServerRequestType( serverRequestType: number, ): ServerRequestType { invariant( serverRequestType === 0 || serverRequestType === 2 || serverRequestType === 3 || serverRequestType === 5 || serverRequestType === 6 || serverRequestType === 7, 'number is not ServerRequestType enum', ); return serverRequestType; } type PlatformServerRequest = {| type: 0, |}; type PlatformClientResponse = {| type: 0, platform: Platform, |}; export type ThreadInconsistencyClientResponse = {| ...ThreadInconsistencyReportShape, type: 2, |}; type PlatformDetailsServerRequest = {| type: 3, |}; type PlatformDetailsClientResponse = {| type: 3, platformDetails: PlatformDetails, |}; export type EntryInconsistencyClientResponse = {| type: 5, ...EntryInconsistencyReportShape, |}; export type CheckStateServerRequest = {| type: 6, hashesToCheck: { [key: string]: number }, failUnmentioned?: $Shape<{| threadInfos: boolean, entryInfos: boolean, userInfos: boolean, |}>, stateChanges?: $Shape<{| rawThreadInfos: RawThreadInfo[], rawEntryInfos: RawEntryInfo[], currentUserInfo: CurrentUserInfo, userInfos: AccountUserInfo[], deleteThreadIDs: string[], deleteEntryIDs: string[], deleteUserInfoIDs: string[], |}>, |}; type CheckStateClientResponse = {| type: 6, hashResults: { [key: string]: boolean }, |}; type InitialActivityUpdatesClientResponse = {| type: 7, activityUpdates: $ReadOnlyArray, |}; export type ServerRequest = | PlatformServerRequest | PlatformDetailsServerRequest | CheckStateServerRequest; export type ClientResponse = | PlatformClientResponse | ThreadInconsistencyClientResponse | PlatformDetailsClientResponse | EntryInconsistencyClientResponse | CheckStateClientResponse | InitialActivityUpdatesClientResponse; // This is just the client variant of ClientResponse. The server needs to handle // multiple client versions so the type supports old versions of certain client // responses, but the client variant only need to support the latest version. type ClientThreadInconsistencyClientResponse = {| ...ClientThreadInconsistencyReportShape, type: 2, |}; type ClientEntryInconsistencyClientResponse = {| type: 5, ...ClientEntryInconsistencyReportShape, |}; export type ClientClientResponse = | PlatformClientResponse | ClientThreadInconsistencyClientResponse | PlatformDetailsClientResponse | ClientEntryInconsistencyClientResponse | CheckStateClientResponse | InitialActivityUpdatesClientResponse; export type ClientInconsistencyResponse = | ClientThreadInconsistencyClientResponse | ClientEntryInconsistencyClientResponse; export const processServerRequestsActionType = 'PROCESS_SERVER_REQUESTS'; export type ProcessServerRequestsPayload = {| serverRequests: $ReadOnlyArray, calendarQuery: CalendarQuery, |}; diff --git a/lib/types/session-types.js b/lib/types/session-types.js index 03bde6a54..30ef95bb2 100644 --- a/lib/types/session-types.js +++ b/lib/types/session-types.js @@ -1,117 +1,117 @@ // @flow +import PropTypes from 'prop-types'; + +import type { LogInActionSource } from './account-types'; +import type { CalendarQuery } from './entry-types'; import type { RawThreadInfo } from './thread-types'; import { type UserInfo, type CurrentUserInfo, type LoggedOutUserInfo, currentUserPropType, } from './user-types'; -import type { CalendarQuery } from './entry-types'; -import type { LogInActionSource } from './account-types'; - -import PropTypes from 'prop-types'; export const cookieLifetime = 30 * 24 * 60 * 60 * 1000; // in milliseconds // Interval the server waits after a state check before starting a new one export const sessionCheckFrequency = 3 * 60 * 1000; // in milliseconds // How long the server debounces after activity before initiating a state check export const stateCheckInactivityActivationInterval = 3 * 1000; // in milliseconds // On native, we specify the cookie directly in the request and response body. // We do this because: // (1) We don't have the same XSS risks as we do on web, so there is no need to // prevent JavaScript from knowing the cookie password. // (2) In the past the internal cookie logic on Android has been buggy. // https://github.com/facebook/react-native/issues/12956 is an example // issue. By specifying the cookie in the body we retain full control of how // that data is passed, without necessitating any native modules like // react-native-cookies. export const cookieSources = Object.freeze({ BODY: 0, HEADER: 1, }); export type CookieSource = $Values; // On native, we use the cookieID as a unique session identifier. This is // because there is no way to have two instances of an app running. On the other // hand, on web it is possible to have two sessions open using the same cookie, // so we have a unique sessionID specified in the request body. export const sessionIdentifierTypes = Object.freeze({ COOKIE_ID: 0, BODY_SESSION_ID: 1, }); export type SessionIdentifierType = $Values; export const cookieTypes = Object.freeze({ USER: 'user', ANONYMOUS: 'anonymous', }); export type CookieType = $Values; export type ServerSessionChange = | {| cookieInvalidated: false, threadInfos: { [id: string]: RawThreadInfo }, userInfos: UserInfo[], sessionID?: null | string, cookie?: string, |} | {| cookieInvalidated: true, threadInfos: { [id: string]: RawThreadInfo }, userInfos: UserInfo[], currentUserInfo: LoggedOutUserInfo, sessionID?: null | string, cookie?: string, |}; export type ClientSessionChange = | {| cookieInvalidated: false, currentUserInfo?: ?CurrentUserInfo, sessionID?: null | string, cookie?: string, |} | {| cookieInvalidated: true, currentUserInfo: LoggedOutUserInfo, sessionID?: null | string, cookie?: string, |}; export type PreRequestUserState = {| currentUserInfo: ?CurrentUserInfo, cookie: ?string, sessionID: ?string, |}; export const preRequestUserStatePropType = PropTypes.shape({ currentUserInfo: currentUserPropType, cookie: PropTypes.string, sessionID: PropTypes.string, }); export type SetSessionPayload = {| sessionChange: ClientSessionChange, preRequestUserState: ?PreRequestUserState, error: ?string, source: ?LogInActionSource, |}; export type SessionState = {| calendarQuery: CalendarQuery, messagesCurrentAsOf: number, updatesCurrentAsOf: number, watchedIDs: $ReadOnlyArray, |}; export type SessionIdentification = $Shape<{| cookie: ?string, sessionID: ?string, |}>; export const sessionIdentificationPropType = PropTypes.shape({ cookie: PropTypes.string, sessionID: PropTypes.string, }); diff --git a/lib/types/socket-types.js b/lib/types/socket-types.js index e4214bf8b..f126f88ae 100644 --- a/lib/types/socket-types.js +++ b/lib/types/socket-types.js @@ -1,314 +1,314 @@ // @flow -import type { SessionState, SessionIdentification } from './session-types'; +import invariant from 'invariant'; +import PropTypes from 'prop-types'; + +import { + type ActivityUpdate, + type UpdateActivityResult, + activityUpdatePropType, +} from './activity-types'; +import type { Platform } from './device-types'; +import type { APIRequest } from './endpoints'; +import { + type RawEntryInfo, + type CalendarQuery, + defaultCalendarQuery, + calendarQueryPropType, +} from './entry-types'; +import type { MessagesResponse, NewMessagesPayload } from './message-types'; import type { ServerRequest, ClientResponse, ClientClientResponse, } from './request-types'; +import type { SessionState, SessionIdentification } from './session-types'; import type { RawThreadInfo } from './thread-types'; -import type { MessagesResponse, NewMessagesPayload } from './message-types'; import type { UpdatesResult, UpdatesResultWithUserInfos } from './update-types'; import type { UserInfo, CurrentUserInfo, LoggedOutUserInfo, } from './user-types'; -import { - type RawEntryInfo, - type CalendarQuery, - defaultCalendarQuery, - calendarQueryPropType, -} from './entry-types'; -import { - type ActivityUpdate, - type UpdateActivityResult, - activityUpdatePropType, -} from './activity-types'; -import type { Platform } from './device-types'; -import type { APIRequest } from './endpoints'; - -import invariant from 'invariant'; -import PropTypes from 'prop-types'; // The types of messages that the client sends across the socket export const clientSocketMessageTypes = Object.freeze({ INITIAL: 0, RESPONSES: 1, //ACTIVITY_UPDATES: 2, (DEPRECATED) PING: 3, ACK_UPDATES: 4, API_REQUEST: 5, }); export type ClientSocketMessageType = $Values; export function assertClientSocketMessageType( ourClientSocketMessageType: number, ): ClientSocketMessageType { invariant( ourClientSocketMessageType === 0 || ourClientSocketMessageType === 1 || ourClientSocketMessageType === 3 || ourClientSocketMessageType === 4 || ourClientSocketMessageType === 5, 'number is not ClientSocketMessageType enum', ); return ourClientSocketMessageType; } export type InitialClientSocketMessage = {| type: 0, id: number, payload: {| sessionIdentification: SessionIdentification, sessionState: SessionState, clientResponses: $ReadOnlyArray, |}, |}; export type ResponsesClientSocketMessage = {| type: 1, id: number, payload: {| clientResponses: $ReadOnlyArray, |}, |}; export type PingClientSocketMessage = {| type: 3, id: number, |}; export type AckUpdatesClientSocketMessage = {| type: 4, id: number, payload: {| currentAsOf: number, |}, |}; export type APIRequestClientSocketMessage = {| type: 5, id: number, payload: APIRequest, |}; export type ClientSocketMessage = | InitialClientSocketMessage | ResponsesClientSocketMessage | PingClientSocketMessage | AckUpdatesClientSocketMessage | APIRequestClientSocketMessage; export type ClientInitialClientSocketMessage = {| type: 0, id: number, payload: {| sessionIdentification: SessionIdentification, sessionState: SessionState, clientResponses: $ReadOnlyArray, |}, |}; export type ClientResponsesClientSocketMessage = {| type: 1, id: number, payload: {| clientResponses: $ReadOnlyArray, |}, |}; export type ClientClientSocketMessage = | ClientInitialClientSocketMessage | ClientResponsesClientSocketMessage | PingClientSocketMessage | AckUpdatesClientSocketMessage | APIRequestClientSocketMessage; export type ClientSocketMessageWithoutID = $Diff< ClientClientSocketMessage, { id: number }, >; // The types of messages that the server sends across the socket export const serverSocketMessageTypes = Object.freeze({ STATE_SYNC: 0, REQUESTS: 1, ERROR: 2, AUTH_ERROR: 3, ACTIVITY_UPDATE_RESPONSE: 4, PONG: 5, UPDATES: 6, MESSAGES: 7, API_RESPONSE: 8, }); export type ServerSocketMessageType = $Values; export function assertServerSocketMessageType( ourServerSocketMessageType: number, ): ServerSocketMessageType { invariant( ourServerSocketMessageType === 0 || ourServerSocketMessageType === 1 || ourServerSocketMessageType === 2 || ourServerSocketMessageType === 3 || ourServerSocketMessageType === 4 || ourServerSocketMessageType === 5 || ourServerSocketMessageType === 6 || ourServerSocketMessageType === 7 || ourServerSocketMessageType === 8, 'number is not ServerSocketMessageType enum', ); return ourServerSocketMessageType; } export const stateSyncPayloadTypes = Object.freeze({ FULL: 0, INCREMENTAL: 1, }); export type FullStateSync = {| messagesResult: MessagesResponse, threadInfos: { [id: string]: RawThreadInfo }, currentUserInfo: CurrentUserInfo, rawEntryInfos: $ReadOnlyArray, userInfos: $ReadOnlyArray, updatesCurrentAsOf: number, |}; export type StateSyncFullActionPayload = {| ...FullStateSync, calendarQuery: CalendarQuery, |}; export const fullStateSyncActionType = 'FULL_STATE_SYNC'; export type StateSyncFullSocketPayload = {| ...FullStateSync, type: 0, // Included iff client is using sessionIdentifierTypes.BODY_SESSION_ID sessionID?: string, |}; export type IncrementalStateSync = {| messagesResult: MessagesResponse, updatesResult: UpdatesResult, deltaEntryInfos: $ReadOnlyArray, deletedEntryIDs: $ReadOnlyArray, userInfos: $ReadOnlyArray, |}; export type StateSyncIncrementalActionPayload = {| ...IncrementalStateSync, calendarQuery: CalendarQuery, |}; export const incrementalStateSyncActionType = 'INCREMENTAL_STATE_SYNC'; type StateSyncIncrementalSocketPayload = {| type: 1, ...IncrementalStateSync, |}; export type StateSyncSocketPayload = | StateSyncFullSocketPayload | StateSyncIncrementalSocketPayload; export type StateSyncServerSocketMessage = {| type: 0, responseTo: number, payload: StateSyncSocketPayload, |}; export type RequestsServerSocketMessage = {| type: 1, responseTo?: number, payload: {| serverRequests: $ReadOnlyArray, |}, |}; export type ErrorServerSocketMessage = {| type: 2, responseTo?: number, message: string, payload?: Object, |}; export type AuthErrorServerSocketMessage = {| type: 3, responseTo: number, message: string, // If unspecified, it is because the client is using cookieSources.HEADER, // which means the server can't update the cookie from a socket message. sessionChange?: { cookie: string, currentUserInfo: LoggedOutUserInfo, }, |}; export type ActivityUpdateResponseServerSocketMessage = {| type: 4, responseTo: number, payload: UpdateActivityResult, |}; export type PongServerSocketMessage = {| type: 5, responseTo: number, |}; export type UpdatesServerSocketMessage = {| type: 6, payload: UpdatesResultWithUserInfos, |}; export type MessagesServerSocketMessage = {| type: 7, payload: NewMessagesPayload, |}; export type APIResponseServerSocketMessage = {| type: 8, responseTo: number, payload: Object, |}; export type ServerSocketMessage = | StateSyncServerSocketMessage | RequestsServerSocketMessage | ErrorServerSocketMessage | AuthErrorServerSocketMessage | ActivityUpdateResponseServerSocketMessage | PongServerSocketMessage | UpdatesServerSocketMessage | MessagesServerSocketMessage | APIResponseServerSocketMessage; export type SocketListener = (message: ServerSocketMessage) => void; export type ConnectionStatus = | 'connecting' | 'connected' | 'reconnecting' | 'disconnecting' | 'forcedDisconnecting' | 'disconnected'; export type ConnectionInfo = {| status: ConnectionStatus, queuedActivityUpdates: $ReadOnlyArray, actualizedCalendarQuery: CalendarQuery, lateResponses: $ReadOnlyArray, showDisconnectedBar: boolean, |}; export const connectionStatusPropType = PropTypes.oneOf([ 'connecting', 'connected', 'reconnecting', 'disconnecting', 'forcedDisconnecting', 'disconnected', ]); export const connectionInfoPropType = PropTypes.shape({ status: connectionStatusPropType.isRequired, queuedActivityUpdates: PropTypes.arrayOf(activityUpdatePropType).isRequired, actualizedCalendarQuery: calendarQueryPropType.isRequired, lateResponses: PropTypes.arrayOf(PropTypes.number).isRequired, showDisconnectedBar: PropTypes.bool.isRequired, }); export const defaultConnectionInfo = (platform: Platform, timeZone?: ?string) => ({ status: 'connecting', queuedActivityUpdates: [], actualizedCalendarQuery: defaultCalendarQuery(platform, timeZone), lateResponses: [], showDisconnectedBar: false, }: ConnectionInfo); export const updateConnectionStatusActionType = 'UPDATE_CONNECTION_STATUS'; export type UpdateConnectionStatusPayload = {| status: ConnectionStatus, |}; export const setLateResponseActionType = 'SET_LATE_RESPONSE'; export type SetLateResponsePayload = {| messageID: number, isLate: boolean, |}; export const updateDisconnectedBarActionType = 'UPDATE_DISCONNECTED_BAR'; export type UpdateDisconnectedBarPayload = {| visible: boolean |}; diff --git a/lib/types/thread-types.js b/lib/types/thread-types.js index 17ac5c4b3..9c7168e63 100644 --- a/lib/types/thread-types.js +++ b/lib/types/thread-types.js @@ -1,381 +1,381 @@ // @flow -import type { ThreadSubscription } from './subscription-types'; -import type { - RawMessageInfo, - MessageTruncationStatuses, -} from './message-types'; -import type { UserInfo, AccountUserInfo } from './user-types'; +import invariant from 'invariant'; +import PropTypes from 'prop-types'; + import type { CalendarQuery, CalendarResult, RawEntryInfo, } from './entry-types'; -import type { UpdateInfo } from './update-types'; +import type { + RawMessageInfo, + MessageTruncationStatuses, +} from './message-types'; import type { ClientThreadInconsistencyReportCreationRequest } from './report-types'; - -import PropTypes from 'prop-types'; -import invariant from 'invariant'; +import type { ThreadSubscription } from './subscription-types'; +import type { UpdateInfo } from './update-types'; +import type { UserInfo, AccountUserInfo } from './user-types'; export const threadTypes = Object.freeze({ //OPEN: 0, (DEPRECATED) //CLOSED: 1, (DEPRECATED) //SECRET: 2, (DEPRECATED) CHAT_NESTED_OPEN: 3, CHAT_SECRET: 4, SIDEBAR: 5, PERSONAL: 6, }); export type ThreadType = $Values; export function assertThreadType(threadType: number): ThreadType { invariant( threadType === 3 || threadType === 4 || threadType === 5 || threadType === 6, 'number is not ThreadType enum', ); return threadType; } export const threadPermissions = Object.freeze({ KNOW_OF: 'know_of', VISIBLE: 'visible', VOICED: 'voiced', EDIT_ENTRIES: 'edit_entries', EDIT_THREAD: 'edit_thread', DELETE_THREAD: 'delete_thread', CREATE_SUBTHREADS: 'create_subthreads', CREATE_SIDEBARS: 'create_sidebars', JOIN_THREAD: 'join_thread', EDIT_PERMISSIONS: 'edit_permissions', ADD_MEMBERS: 'add_members', REMOVE_MEMBERS: 'remove_members', CHANGE_ROLE: 'change_role', LEAVE_THREAD: 'leave_thread', }); export type ThreadPermission = $Values; export function assertThreadPermissions( ourThreadPermissions: string, ): ThreadPermission { invariant( ourThreadPermissions === 'know_of' || ourThreadPermissions === 'visible' || ourThreadPermissions === 'voiced' || ourThreadPermissions === 'edit_entries' || ourThreadPermissions === 'edit_thread' || ourThreadPermissions === 'delete_thread' || ourThreadPermissions === 'create_subthreads' || ourThreadPermissions === 'create_sidebars' || ourThreadPermissions === 'join_thread' || ourThreadPermissions === 'edit_permissions' || ourThreadPermissions === 'add_members' || ourThreadPermissions === 'remove_members' || ourThreadPermissions === 'change_role' || ourThreadPermissions === 'leave_thread', 'string is not threadPermissions enum', ); return ourThreadPermissions; } export const threadPermissionPrefixes = Object.freeze({ DESCENDANT: 'descendant_', CHILD: 'child_', OPEN: 'open_', OPEN_DESCENDANT: 'descendant_open_', }); export type ThreadPermissionInfo = | {| value: true, source: string |} | {| value: false, source: null |}; export type ThreadPermissionsBlob = { [permission: string]: ThreadPermissionInfo, }; export type ThreadRolePermissionsBlob = { [permission: string]: boolean }; export type ThreadPermissionsInfo = { [permission: ThreadPermission]: ThreadPermissionInfo, }; export const threadPermissionsInfoPropType = PropTypes.objectOf( PropTypes.oneOfType([ PropTypes.shape({ value: PropTypes.oneOf([true]), source: PropTypes.string.isRequired, }), PropTypes.shape({ value: PropTypes.oneOf([false]), source: PropTypes.oneOf([null]), }), ]), ); export type MemberInfo = {| id: string, role: ?string, permissions: ThreadPermissionsInfo, |}; export const memberInfoPropType = PropTypes.shape({ id: PropTypes.string.isRequired, role: PropTypes.string, permissions: threadPermissionsInfoPropType.isRequired, }); export type RelativeMemberInfo = {| ...MemberInfo, username: ?string, isViewer: boolean, |}; export const relativeMemberInfoPropType = PropTypes.shape({ id: PropTypes.string.isRequired, role: PropTypes.string, permissions: threadPermissionsInfoPropType.isRequired, username: PropTypes.string, isViewer: PropTypes.bool.isRequired, }); export type RoleInfo = {| id: string, name: string, permissions: ThreadRolePermissionsBlob, isDefault: boolean, |}; export type ThreadCurrentUserInfo = {| role: ?string, permissions: ThreadPermissionsInfo, subscription: ThreadSubscription, unread: ?boolean, |}; export type RawThreadInfo = {| id: string, type: ThreadType, name: ?string, description: ?string, color: string, // hex, without "#" or "0x" creationTime: number, // millisecond timestamp parentThreadID: ?string, members: $ReadOnlyArray, roles: { [id: string]: RoleInfo }, currentUser: ThreadCurrentUserInfo, |}; export type ThreadInfo = {| id: string, type: ThreadType, name: ?string, uiName: string, description: ?string, color: string, // hex, without "#" or "0x" creationTime: number, // millisecond timestamp parentThreadID: ?string, members: $ReadOnlyArray, roles: { [id: string]: RoleInfo }, currentUser: ThreadCurrentUserInfo, |}; export const threadTypePropType = PropTypes.oneOf([ threadTypes.CHAT_NESTED_OPEN, threadTypes.CHAT_SECRET, threadTypes.SIDEBAR, threadTypes.PERSONAL, ]); const rolePropType = PropTypes.shape({ id: PropTypes.string.isRequired, name: PropTypes.string.isRequired, permissions: PropTypes.objectOf(PropTypes.bool).isRequired, isDefault: PropTypes.bool.isRequired, }); const currentUserPropType = PropTypes.shape({ role: PropTypes.string, permissions: threadPermissionsInfoPropType.isRequired, subscription: PropTypes.shape({ pushNotifs: PropTypes.bool.isRequired, home: PropTypes.bool.isRequired, }).isRequired, unread: PropTypes.bool, }); export const rawThreadInfoPropType = PropTypes.shape({ id: PropTypes.string.isRequired, type: threadTypePropType.isRequired, name: PropTypes.string, description: PropTypes.string, color: PropTypes.string.isRequired, creationTime: PropTypes.number.isRequired, parentThreadID: PropTypes.string, members: PropTypes.arrayOf(memberInfoPropType).isRequired, roles: PropTypes.objectOf(rolePropType).isRequired, currentUser: currentUserPropType.isRequired, }); export const threadInfoPropType = PropTypes.shape({ id: PropTypes.string.isRequired, type: threadTypePropType.isRequired, name: PropTypes.string, uiName: PropTypes.string.isRequired, description: PropTypes.string, color: PropTypes.string.isRequired, creationTime: PropTypes.number.isRequired, parentThreadID: PropTypes.string, members: PropTypes.arrayOf(memberInfoPropType).isRequired, roles: PropTypes.objectOf(rolePropType).isRequired, currentUser: currentUserPropType.isRequired, }); export type ServerMemberInfo = {| id: string, role: ?string, permissions: ThreadPermissionsInfo, subscription: ThreadSubscription, unread: ?boolean, |}; export type ServerThreadInfo = {| id: string, type: ThreadType, name: ?string, description: ?string, color: string, // hex, without "#" or "0x" creationTime: number, // millisecond timestamp parentThreadID: ?string, members: $ReadOnlyArray, roles: { [id: string]: RoleInfo }, |}; export type ThreadStore = {| threadInfos: { [id: string]: RawThreadInfo }, inconsistencyReports: $ReadOnlyArray, |}; export type ThreadDeletionRequest = {| threadID: string, accountPassword: string, |}; export type RemoveMembersRequest = {| threadID: string, memberIDs: $ReadOnlyArray, |}; export type RoleChangeRequest = {| threadID: string, memberIDs: $ReadOnlyArray, role: string, |}; export type ChangeThreadSettingsResult = {| threadInfo?: RawThreadInfo, threadInfos?: { [id: string]: RawThreadInfo }, updatesResult: { newUpdates: $ReadOnlyArray, }, newMessageInfos: $ReadOnlyArray, |}; export type ChangeThreadSettingsPayload = {| threadID: string, updatesResult: { newUpdates: $ReadOnlyArray, }, newMessageInfos: $ReadOnlyArray, |}; export type LeaveThreadRequest = {| threadID: string, |}; export type LeaveThreadResult = {| threadInfos?: { [id: string]: RawThreadInfo }, updatesResult: { newUpdates: $ReadOnlyArray, }, |}; export type LeaveThreadPayload = {| updatesResult: { newUpdates: $ReadOnlyArray, }, |}; export type ThreadChanges = $Shape<{| type: ThreadType, name: string, description: string, color: string, parentThreadID: string, newMemberIDs: $ReadOnlyArray, |}>; export type UpdateThreadRequest = {| threadID: string, changes: ThreadChanges, |}; export type NewThreadRequest = {| type: ThreadType, name?: ?string, description?: ?string, color?: ?string, parentThreadID?: ?string, initialMemberIDs?: ?$ReadOnlyArray, |}; export type NewThreadResponse = {| updatesResult: { newUpdates: $ReadOnlyArray, }, newMessageInfos: $ReadOnlyArray, newThreadInfo?: RawThreadInfo, newThreadID?: string, |}; export type NewThreadResult = {| updatesResult: { newUpdates: $ReadOnlyArray, }, newMessageInfos: $ReadOnlyArray, newThreadID: string, |}; export type ServerThreadJoinRequest = {| threadID: string, calendarQuery?: ?CalendarQuery, |}; export type ClientThreadJoinRequest = {| threadID: string, calendarQuery: CalendarQuery, |}; export type ThreadJoinResult = {| threadInfos?: { [id: string]: RawThreadInfo }, updatesResult: { newUpdates: $ReadOnlyArray, }, rawMessageInfos: $ReadOnlyArray, truncationStatuses: MessageTruncationStatuses, userInfos: { [string]: AccountUserInfo }, rawEntryInfos?: ?$ReadOnlyArray, |}; export type ThreadJoinPayload = {| updatesResult: { newUpdates: $ReadOnlyArray, }, rawMessageInfos: RawMessageInfo[], truncationStatuses: MessageTruncationStatuses, userInfos: $ReadOnlyArray, calendarResult: CalendarResult, |}; export type SidebarInfo = {| +threadInfo: ThreadInfo, +lastUpdatedTime: number, +mostRecentNonLocalMessage: ?string, |}; // We can show a max of 3 sidebars inline underneath their parent in the chat // tab. If there are more, we show a button that opens a modal to see the rest export const maxReadSidebars = 3; // We can show a max of 5 sidebars inline underneath their parent // in the chat tab if every one of the displayed sidebars is unread export const maxUnreadSidebars = 5; diff --git a/lib/types/update-types.js b/lib/types/update-types.js index d47daeff0..e85e6abe7 100644 --- a/lib/types/update-types.js +++ b/lib/types/update-types.js @@ -1,278 +1,278 @@ // @flow -import type { RawThreadInfo } from './thread-types'; -import type { RawMessageInfo, MessageTruncationStatus } from './message-types'; +import invariant from 'invariant'; + import type { RawEntryInfo } from './entry-types'; +import type { RawMessageInfo, MessageTruncationStatus } from './message-types'; +import type { RawThreadInfo } from './thread-types'; import type { UserInfo, AccountUserInfo, LoggedInUserInfo } from './user-types'; -import invariant from 'invariant'; - export const updateTypes = Object.freeze({ DELETE_ACCOUNT: 0, UPDATE_THREAD: 1, UPDATE_THREAD_READ_STATUS: 2, DELETE_THREAD: 3, JOIN_THREAD: 4, BAD_DEVICE_TOKEN: 5, UPDATE_ENTRY: 6, UPDATE_CURRENT_USER: 7, UPDATE_USER: 8, }); export type UpdateType = $Values; export function assertUpdateType(ourUpdateType: number): UpdateType { invariant( ourUpdateType === 0 || ourUpdateType === 1 || ourUpdateType === 2 || ourUpdateType === 3 || ourUpdateType === 4 || ourUpdateType === 5 || ourUpdateType === 6 || ourUpdateType === 7 || ourUpdateType === 8, 'number is not UpdateType enum', ); return ourUpdateType; } type AccountDeletionData = {| deletedUserID: string, |}; type ThreadData = {| threadID: string, |}; type ThreadReadStatusData = {| threadID: string, unread: boolean, |}; type ThreadDeletionData = {| threadID: string, |}; type ThreadJoinData = {| threadID: string, |}; type BadDeviceTokenData = {| deviceToken: string, |}; type EntryData = {| entryID: string, |}; type CurrentUserData = {||}; type UserData = {| // ID of the UserInfo being updated updatedUserID: string, |}; type SharedUpdateData = {| userID: string, time: number, |}; type AccountDeletionUpdateData = {| ...SharedUpdateData, ...AccountDeletionData, type: 0, |}; type ThreadUpdateData = {| ...SharedUpdateData, ...ThreadData, type: 1, targetSession?: string, |}; type ThreadReadStatusUpdateData = {| ...SharedUpdateData, ...ThreadReadStatusData, type: 2, |}; type ThreadDeletionUpdateData = {| ...SharedUpdateData, ...ThreadDeletionData, type: 3, |}; type ThreadJoinUpdateData = {| ...SharedUpdateData, ...ThreadJoinData, type: 4, |}; type BadDeviceTokenUpdateData = {| ...SharedUpdateData, ...BadDeviceTokenData, type: 5, targetCookie: string, |}; type EntryUpdateData = {| ...SharedUpdateData, ...EntryData, type: 6, targetSession: string, |}; type CurrentUserUpdateData = {| ...SharedUpdateData, ...CurrentUserData, type: 7, |}; type UserUpdateData = {| ...SharedUpdateData, ...UserData, type: 8, |}; export type UpdateData = | AccountDeletionUpdateData | ThreadUpdateData | ThreadReadStatusUpdateData | ThreadDeletionUpdateData | ThreadJoinUpdateData | BadDeviceTokenUpdateData | EntryUpdateData | CurrentUserUpdateData | UserUpdateData; type SharedRawUpdateInfo = {| id: string, time: number, |}; type AccountDeletionRawUpdateInfo = {| ...SharedRawUpdateInfo, ...AccountDeletionData, type: 0, |}; type ThreadRawUpdateInfo = {| ...SharedRawUpdateInfo, ...ThreadData, type: 1, |}; type ThreadReadStatusRawUpdateInfo = {| ...SharedRawUpdateInfo, ...ThreadReadStatusData, type: 2, |}; type ThreadDeletionRawUpdateInfo = {| ...SharedRawUpdateInfo, ...ThreadDeletionData, type: 3, |}; type ThreadJoinRawUpdateInfo = {| ...SharedRawUpdateInfo, ...ThreadJoinData, type: 4, |}; type BadDeviceTokenRawUpdateInfo = {| ...SharedRawUpdateInfo, ...BadDeviceTokenData, type: 5, |}; type EntryRawUpdateInfo = {| ...SharedRawUpdateInfo, ...EntryData, type: 6, |}; type CurrentUserRawUpdateInfo = {| ...SharedRawUpdateInfo, ...CurrentUserData, type: 7, |}; type UserRawUpdateInfo = {| ...SharedRawUpdateInfo, ...UserData, type: 8, |}; export type RawUpdateInfo = | AccountDeletionRawUpdateInfo | ThreadRawUpdateInfo | ThreadReadStatusRawUpdateInfo | ThreadDeletionRawUpdateInfo | ThreadJoinRawUpdateInfo | BadDeviceTokenRawUpdateInfo | EntryRawUpdateInfo | CurrentUserRawUpdateInfo | UserRawUpdateInfo; type AccountDeletionUpdateInfo = {| type: 0, id: string, time: number, deletedUserID: string, |}; type ThreadUpdateInfo = {| type: 1, id: string, time: number, threadInfo: RawThreadInfo, |}; type ThreadReadStatusUpdateInfo = {| type: 2, id: string, time: number, threadID: string, unread: boolean, |}; type ThreadDeletionUpdateInfo = {| type: 3, id: string, time: number, threadID: string, |}; type ThreadJoinUpdateInfo = {| type: 4, id: string, time: number, threadInfo: RawThreadInfo, rawMessageInfos: $ReadOnlyArray, truncationStatus: MessageTruncationStatus, rawEntryInfos: $ReadOnlyArray, |}; type BadDeviceTokenUpdateInfo = {| type: 5, id: string, time: number, deviceToken: string, |}; type EntryUpdateInfo = {| type: 6, id: string, time: number, entryInfo: RawEntryInfo, |}; type CurrentUserUpdateInfo = {| type: 7, id: string, time: number, currentUserInfo: LoggedInUserInfo, |}; type UserUpdateInfo = {| type: 8, id: string, time: number, // Updated UserInfo is already contained within the UpdatesResultWithUserInfos updatedUserID: string, |}; export type UpdateInfo = | AccountDeletionUpdateInfo | ThreadUpdateInfo | ThreadReadStatusUpdateInfo | ThreadDeletionUpdateInfo | ThreadJoinUpdateInfo | BadDeviceTokenUpdateInfo | EntryUpdateInfo | CurrentUserUpdateInfo | UserUpdateInfo; export type UpdatesResult = {| currentAsOf: number, newUpdates: $ReadOnlyArray, |}; export type UpdatesResultWithUserInfos = {| updatesResult: UpdatesResult, userInfos: $ReadOnlyArray, |}; export type CreateUpdatesResult = {| viewerUpdates: $ReadOnlyArray, userInfos: { [id: string]: AccountUserInfo }, |}; export type CreateUpdatesResponse = {| viewerUpdates: $ReadOnlyArray, userInfos: $ReadOnlyArray, |}; export const processUpdatesActionType = 'PROCESS_UPDATES'; diff --git a/lib/types/user-types.js b/lib/types/user-types.js index 1c9245e96..5063ac515 100644 --- a/lib/types/user-types.js +++ b/lib/types/user-types.js @@ -1,109 +1,109 @@ // @flow -import type { UserInconsistencyReportCreationRequest } from './report-types'; -import type { UserRelationshipStatus } from './relationship-types'; - import PropTypes from 'prop-types'; +import type { UserRelationshipStatus } from './relationship-types'; +import type { UserInconsistencyReportCreationRequest } from './report-types'; + export type GlobalUserInfo = {| +id: string, +username: ?string, |}; export type GlobalAccountUserInfo = {| +id: string, +username: string, |}; export type UserInfo = {| +id: string, +username: ?string, +relationshipStatus?: UserRelationshipStatus, |}; export type UserInfos = { +[id: string]: UserInfo }; export const userInfoPropType = PropTypes.shape({ id: PropTypes.string.isRequired, username: PropTypes.string, relationshipStatus: PropTypes.number, }); export type AccountUserInfo = {| +id: string, +username: string, +relationshipStatus?: UserRelationshipStatus, |}; export const accountUserInfoPropType = PropTypes.shape({ id: PropTypes.string.isRequired, username: PropTypes.string.isRequired, relationshipStatus: PropTypes.number, }); export type UserStore = {| +userInfos: UserInfos, +inconsistencyReports: $ReadOnlyArray, |}; export type RelativeUserInfo = {| +id: string, +username: ?string, +isViewer: boolean, |}; export const relativeUserInfoPropType = PropTypes.shape({ id: PropTypes.string.isRequired, username: PropTypes.string, isViewer: PropTypes.bool.isRequired, }); export type LoggedInUserInfo = {| +id: string, +username: string, +email: string, +emailVerified: boolean, |}; export type LoggedOutUserInfo = {| +id: string, +anonymous: true, |}; export type CurrentUserInfo = LoggedInUserInfo | LoggedOutUserInfo; export const currentUserPropType = PropTypes.oneOfType([ PropTypes.shape({ id: PropTypes.string.isRequired, username: PropTypes.string.isRequired, email: PropTypes.string.isRequired, emailVerified: PropTypes.bool.isRequired, }), PropTypes.shape({ id: PropTypes.string.isRequired, anonymous: PropTypes.oneOf([true]).isRequired, }), ]); export type AccountUpdate = {| +updatedFields: {| +email?: ?string, +password?: ?string, |}, +currentPassword: string, |}; export type UserListItem = {| +id: string, +username: string, +disabled?: boolean, +notice?: string, +alertText?: string, |}; export const userListItemPropType = PropTypes.shape({ id: PropTypes.string.isRequired, username: PropTypes.string.isRequired, disabled: PropTypes.bool, notice: PropTypes.string, alertText: PropTypes.string, }); diff --git a/lib/utils/action-logger.js b/lib/utils/action-logger.js index fc549c9b1..0d487d087 100644 --- a/lib/utils/action-logger.js +++ b/lib/utils/action-logger.js @@ -1,181 +1,181 @@ // @flow -import { rehydrateActionType } from '../types/redux-types'; -import type { ActionSummary } from '../types/report-types'; - import inspect from 'util-inspect'; import { saveDraftActionType } from '../actions/miscellaneous-action-types'; +import { rehydrateActionType } from '../types/redux-types'; +import type { ActionSummary } from '../types/report-types'; + import { sanitizeAction } from './sanitization'; const uninterestingActionTypes = new Set([ saveDraftActionType, 'Navigation/COMPLETE_TRANSITION', ]); const maxActionSummaryLength = 500; type Subscriber = (action: Object, state: Object) => void; class ActionLogger { static n = 30; lastNActions = []; lastNStates = []; currentReduxState = undefined; currentOtherStates = {}; subscribers: Subscriber[] = []; get preloadedState(): Object { return this.lastNStates[0].state; } get actions(): Object[] { return this.lastNActions.map(({ action }) => action); } get interestingActionSummaries(): ActionSummary[] { return this.lastNActions .filter(({ action }) => !uninterestingActionTypes.has(action.type)) .map(({ action, time }) => ({ type: action.type, time, summary: ActionLogger.getSummaryForAction(action), })); } static getSummaryForAction(action: Object): string { const sanitized = sanitizeAction(action); let summary, length, depth = 3; do { summary = inspect(sanitized, { depth }); length = summary.length; depth--; } while (length > maxActionSummaryLength && depth > 0); return summary; } prepareForAction() { if ( this.lastNActions.length > 0 && this.lastNActions[this.lastNActions.length - 1].action.type === rehydrateActionType ) { // redux-persist can't handle replaying REHYDRATE // https://github.com/rt2zz/redux-persist/issues/743 this.lastNActions = []; this.lastNStates = []; } if (this.lastNActions.length === ActionLogger.n) { this.lastNActions.shift(); this.lastNStates.shift(); } } addReduxAction(action: Object, beforeState: Object, afterState: Object) { this.prepareForAction(); if (this.currentReduxState === undefined) { for (let i = 0; i < this.lastNStates.length; i++) { this.lastNStates[i] = { ...this.lastNStates[i], state: { ...this.lastNStates[i].state, ...beforeState, }, }; } } this.currentReduxState = afterState; const state = { ...beforeState }; for (let stateKey in this.currentOtherStates) { state[stateKey] = this.currentOtherStates[stateKey]; } const time = Date.now(); this.lastNActions.push({ action, time }); this.lastNStates.push({ state, time }); this.triggerSubscribers(action); } addOtherAction( key: string, action: Object, beforeState: Object, afterState: Object, ) { this.prepareForAction(); const currentState = this.currentOtherStates[key]; if (currentState === undefined) { for (let i = 0; i < this.lastNStates.length; i++) { this.lastNStates[i] = { ...this.lastNStates[i], state: { ...this.lastNStates[i].state, [key]: beforeState, }, }; } } this.currentOtherStates[key] = afterState; const state = { ...this.currentState, [key]: beforeState, }; const time = Date.now(); this.lastNActions.push({ action, time }); this.lastNStates.push({ state, time }); this.triggerSubscribers(action); } get mostRecentActionTime(): ?number { if (this.lastNActions.length === 0) { return null; } return this.lastNActions[this.lastNActions.length - 1].time; } get currentState(): Object { const state = this.currentReduxState ? { ...this.currentReduxState } : {}; for (let stateKey in this.currentOtherStates) { state[stateKey] = this.currentOtherStates[stateKey]; } return state; } subscribe(subscriber: Subscriber) { this.subscribers.push(subscriber); } unsubscribe(subscriber: Subscriber) { this.subscribers = this.subscribers.filter( (candidate) => candidate !== subscriber, ); } triggerSubscribers(action: Object) { if (uninterestingActionTypes.has(action.type)) { return; } const state = this.currentState; this.subscribers.forEach((subscriber) => subscriber(action, state)); } } const actionLogger = new ActionLogger(); const reduxLoggerMiddleware = (store: *) => (next: *) => (action: *) => { const beforeState = store.getState(); const result = next(action); const afterState = store.getState(); actionLogger.addReduxAction(action, beforeState, afterState); return result; }; export { actionLogger, reduxLoggerMiddleware }; diff --git a/lib/utils/action-utils.js b/lib/utils/action-utils.js index 972bfc0f1..92c2a4083 100644 --- a/lib/utils/action-utils.js +++ b/lib/utils/action-utils.js @@ -1,499 +1,499 @@ // @flow -import type { - ActionPayload, - Dispatch, - PromisedAction, - BaseAction, -} from '../types/redux-types'; -import type { LoadingOptions, LoadingInfo } from '../types/loading-types'; -import type { FetchJSON, FetchJSONOptions } from './fetch-json'; -import type { CurrentUserInfo } from '../types/user-types'; +import invariant from 'invariant'; +import _mapValues from 'lodash/fp/mapValues'; +import _memoize from 'lodash/memoize'; +import * as React from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { createSelector } from 'reselect'; + +import { cookieInvalidationResolutionAttempt } from '../actions/user-actions'; +import { serverCallStateSelector } from '../selectors/server-calls'; import type { LogInActionSource, LogInStartingPayload, LogInResult, } from '../types/account-types'; import type { Endpoint, SocketAPIHandler } from '../types/endpoints'; +import type { LoadingOptions, LoadingInfo } from '../types/loading-types'; +import type { + ActionPayload, + Dispatch, + PromisedAction, + BaseAction, +} from '../types/redux-types'; import type { ClientSessionChange, PreRequestUserState, } from '../types/session-types'; import type { ConnectionStatus } from '../types/socket-types'; +import type { CurrentUserInfo } from '../types/user-types'; -import invariant from 'invariant'; -import _mapValues from 'lodash/fp/mapValues'; -import { createSelector } from 'reselect'; -import _memoize from 'lodash/memoize'; -import * as React from 'react'; -import { useSelector, useDispatch } from 'react-redux'; - -import fetchJSON from './fetch-json'; import { getConfig } from './config'; -import { cookieInvalidationResolutionAttempt } from '../actions/user-actions'; -import { serverCallStateSelector } from '../selectors/server-calls'; +import fetchJSON from './fetch-json'; +import type { FetchJSON, FetchJSONOptions } from './fetch-json'; let nextPromiseIndex = 0; export type ActionTypes = { started: AT, success: BT, failed: CT, }; function wrapActionPromise< AT: string, // *_STARTED action type (string literal) AP: ActionPayload, // *_STARTED payload BT: string, // *_SUCCESS action type (string literal) BP: ActionPayload, // *_SUCCESS payload CT: string, // *_FAILED action type (string literal) >( actionTypes: ActionTypes, promise: Promise, loadingOptions: ?LoadingOptions, startingPayload: ?AP, ): PromisedAction { const loadingInfo: LoadingInfo = { fetchIndex: nextPromiseIndex++, trackMultipleRequests: !!( loadingOptions && loadingOptions.trackMultipleRequests ), customKeyName: loadingOptions && loadingOptions.customKeyName ? loadingOptions.customKeyName : null, }; return async (dispatch: Dispatch): Promise => { const startAction = startingPayload ? { type: (actionTypes.started: AT), loadingInfo, payload: (startingPayload: AP), } : { type: (actionTypes.started: AT), loadingInfo, }; dispatch(startAction); try { const result = await promise; dispatch({ type: (actionTypes.success: BT), payload: (result: BP), loadingInfo, }); } catch (e) { console.log(e); dispatch({ type: (actionTypes.failed: CT), error: true, payload: (e: Error), loadingInfo, }); } }; } export type DispatchActionPayload = ( actionType: T, payload: P, ) => void; export type DispatchActionPromise = < A: BaseAction, B: BaseAction, C: BaseAction, >( actionTypes: ActionTypes< $PropertyType, $PropertyType, $PropertyType, >, promise: Promise<$PropertyType>, loadingOptions?: LoadingOptions, startingPayload?: $PropertyType, ) => Promise; function useDispatchActionPromise() { const dispatch = useDispatch(); return React.useMemo(() => createDispatchActionPromise(dispatch), [dispatch]); } function createDispatchActionPromise(dispatch: Dispatch) { const dispatchActionPromise = function < A: BaseAction, B: BaseAction, C: BaseAction, >( actionTypes: ActionTypes< $PropertyType, $PropertyType, $PropertyType, >, promise: Promise<$PropertyType>, loadingOptions?: LoadingOptions, startingPayload?: $PropertyType, ): Promise { return dispatch( wrapActionPromise(actionTypes, promise, loadingOptions, startingPayload), ); }; return dispatchActionPromise; } export type DispatchFunctions = {| +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, |}; type LegacyDispatchFunctions = { dispatch: Dispatch, dispatchActionPayload: DispatchActionPayload, dispatchActionPromise: DispatchActionPromise, }; function includeDispatchActionProps( dispatch: Dispatch, ): LegacyDispatchFunctions { const dispatchActionPromise = createDispatchActionPromise(dispatch); const dispatchActionPayload = function ( actionType: T, payload: P, ) { const action = { type: actionType, payload }; dispatch(action); }; return { dispatch, dispatchActionPayload, dispatchActionPromise }; } let currentlyWaitingForNewCookie = false; let fetchJSONCallsWaitingForNewCookie: ((fetchJSON: ?FetchJSON) => void)[] = []; export type DispatchRecoveryAttempt = ( actionTypes: ActionTypes<'LOG_IN_STARTED', 'LOG_IN_SUCCESS', 'LOG_IN_FAILED'>, promise: Promise, startingPayload: LogInStartingPayload, ) => Promise; const setNewSessionActionType = 'SET_NEW_SESSION'; function setNewSession( dispatch: Dispatch, sessionChange: ClientSessionChange, preRequestUserState: ?PreRequestUserState, error: ?string, source: ?LogInActionSource, ) { dispatch({ type: setNewSessionActionType, payload: { sessionChange, preRequestUserState, error, source }, }); } // This function calls resolveInvalidatedCookie, which dispatchs a log in action // using the native credentials. Note that we never actually specify a sessionID // here, on the assumption that only native clients will call this. (Native // clients don't specify a sessionID, indicating to the server that it should // use the cookieID as the sessionID.) async function fetchNewCookieFromNativeCredentials( dispatch: Dispatch, cookie: ?string, urlPrefix: string, source: LogInActionSource, ): Promise { const resolveInvalidatedCookie = getConfig().resolveInvalidatedCookie; if (!resolveInvalidatedCookie) { return null; } let newSessionChange = null; let fetchJSONCallback = null; const boundFetchJSON = async ( endpoint: Endpoint, data: { [key: string]: mixed }, options?: ?FetchJSONOptions, ) => { const innerBoundSetNewSession = ( sessionChange: ClientSessionChange, error: ?string, ) => { newSessionChange = sessionChange; setNewSession(dispatch, sessionChange, null, error, source); }; try { const result = await fetchJSON( cookie, innerBoundSetNewSession, () => new Promise((r) => r(null)), () => new Promise((r) => r(null)), urlPrefix, null, 'disconnected', null, endpoint, data, options, ); if (fetchJSONCallback) { fetchJSONCallback(!!newSessionChange); } return result; } catch (e) { if (fetchJSONCallback) { fetchJSONCallback(!!newSessionChange); } throw e; } }; const dispatchRecoveryAttempt = ( actionTypes: ActionTypes< 'LOG_IN_STARTED', 'LOG_IN_SUCCESS', 'LOG_IN_FAILED', >, promise: Promise, inputStartingPayload: LogInStartingPayload, ) => { const startingPayload = { ...inputStartingPayload, source }; dispatch(wrapActionPromise(actionTypes, promise, null, startingPayload)); return new Promise((r) => (fetchJSONCallback = r)); }; await resolveInvalidatedCookie( boundFetchJSON, dispatchRecoveryAttempt, source, ); return newSessionChange; } // Third param is optional and gets called with newCookie if we get a new cookie // Necessary to propagate cookie in cookieInvalidationRecovery below function bindCookieAndUtilsIntoFetchJSON( params: BindServerCallsParams, ): FetchJSON { const { dispatch, cookie, urlPrefix, sessionID, currentUserInfo, connectionStatus, } = params; const loggedIn = !!(currentUserInfo && !currentUserInfo.anonymous && true); const boundSetNewSession = ( sessionChange: ClientSessionChange, error: ?string, ) => setNewSession( dispatch, sessionChange, { currentUserInfo, cookie, sessionID }, error, undefined, ); // This function gets called before fetchJSON sends a request, to make sure // that we're not in the middle of trying to recover an invalidated cookie const waitIfCookieInvalidated = () => { if (!getConfig().resolveInvalidatedCookie) { // If there is no resolveInvalidatedCookie function, just let the caller // fetchJSON instance continue return Promise.resolve(null); } if (!currentlyWaitingForNewCookie) { // Our cookie seems to be valid return Promise.resolve(null); } // Wait to run until we get our new cookie return new Promise((r) => fetchJSONCallsWaitingForNewCookie.push(r)); }; // This function is a helper for the next function defined below const attemptToResolveInvalidation = async ( sessionChange: ClientSessionChange, ) => { const newAnonymousCookie = sessionChange.cookie; const newSessionChange = await fetchNewCookieFromNativeCredentials( dispatch, newAnonymousCookie, urlPrefix, cookieInvalidationResolutionAttempt, ); currentlyWaitingForNewCookie = false; const currentWaitingCalls = fetchJSONCallsWaitingForNewCookie; fetchJSONCallsWaitingForNewCookie = []; const newFetchJSON = newSessionChange ? bindCookieAndUtilsIntoFetchJSON({ ...params, cookie: newSessionChange.cookie, sessionID: newSessionChange.sessionID, currentUserInfo: newSessionChange.currentUserInfo, }) : null; for (const func of currentWaitingCalls) { func(newFetchJSON); } return newFetchJSON; }; // If this function is called, fetchJSON got a response invalidating its // cookie, and is wondering if it should just like... give up? Or if there's // a chance at redemption const cookieInvalidationRecovery = (sessionChange: ClientSessionChange) => { if (!getConfig().resolveInvalidatedCookie) { // If there is no resolveInvalidatedCookie function, just let the caller // fetchJSON instance continue return Promise.resolve(null); } if (!loggedIn) { // We don't want to attempt any use native credentials of a logged out // user to log-in after a cookieInvalidation while logged out return Promise.resolve(null); } if (currentlyWaitingForNewCookie) { return new Promise((r) => fetchJSONCallsWaitingForNewCookie.push(r)); } currentlyWaitingForNewCookie = true; return attemptToResolveInvalidation(sessionChange); }; return (endpoint: Endpoint, data: Object, options?: ?FetchJSONOptions) => fetchJSON( cookie, boundSetNewSession, waitIfCookieInvalidated, cookieInvalidationRecovery, urlPrefix, sessionID, connectionStatus, socketAPIHandler, endpoint, data, options, ); } export type ActionFunc = ( fetchJSON: FetchJSON, ...rest: $FlowFixMe ) => Promise<*>; type BindServerCallsParams = {| dispatch: Dispatch, cookie: ?string, urlPrefix: string, sessionID: ?string, currentUserInfo: ?CurrentUserInfo, connectionStatus: ConnectionStatus, |}; // All server calls needs to include some information from the Redux state // (namely, the cookie). This information is used deep in the server call, // at the point where fetchJSON is called. We don't want to bother propagating // the cookie (and any future config info that fetchJSON needs) through to the // server calls so they can pass it to fetchJSON. Instead, we "curry" the cookie // onto fetchJSON within react-redux's connect's mapStateToProps function, and // then pass that "bound" fetchJSON that no longer needs the cookie as a // parameter on to the server call. const baseCreateBoundServerCallsSelector = (actionFunc: ActionFunc) => { return createSelector( (state: BindServerCallsParams) => state.dispatch, (state: BindServerCallsParams) => state.cookie, (state: BindServerCallsParams) => state.urlPrefix, (state: BindServerCallsParams) => state.sessionID, (state: BindServerCallsParams) => state.currentUserInfo, (state: BindServerCallsParams) => state.connectionStatus, ( dispatch: Dispatch, cookie: ?string, urlPrefix: string, sessionID: ?string, currentUserInfo: ?CurrentUserInfo, connectionStatus: ConnectionStatus, ) => { const boundFetchJSON = bindCookieAndUtilsIntoFetchJSON({ dispatch, cookie, urlPrefix, sessionID, currentUserInfo, connectionStatus, }); return (...rest: $FlowFixMe) => actionFunc(boundFetchJSON, ...rest); }, ); }; const createBoundServerCallsSelector: ( actionFunc: ActionFunc, ) => (state: BindServerCallsParams) => BoundServerCall = _memoize( baseCreateBoundServerCallsSelector, ); export type ServerCalls = { [name: string]: ActionFunc }; export type BoundServerCall = (...rest: $FlowFixMe) => Promise; function bindServerCalls(serverCalls: ServerCalls) { return ( stateProps: { cookie: ?string, urlPrefix: string, sessionID: ?string, currentUserInfo: ?CurrentUserInfo, connectionStatus: ConnectionStatus, }, dispatchProps: Object, ownProps: { [propName: string]: mixed }, ) => { const dispatch = dispatchProps.dispatch; invariant(dispatch, 'should be defined'); const { cookie, urlPrefix, sessionID, currentUserInfo, connectionStatus, } = stateProps; const boundServerCalls = _mapValues( (serverCall: (fetchJSON: FetchJSON, ...rest: any) => Promise) => createBoundServerCallsSelector(serverCall)({ dispatch, cookie, urlPrefix, sessionID, currentUserInfo, connectionStatus, }), )(serverCalls); return { ...ownProps, ...stateProps, ...dispatchProps, ...boundServerCalls, }; }; } function useServerCall(serverCall: ActionFunc): BoundServerCall { const dispatch = useDispatch(); const serverCallState = useSelector((state) => serverCallStateSelector(state), ); return React.useMemo( () => createBoundServerCallsSelector(serverCall)({ ...serverCallState, dispatch, }), [serverCall, dispatch, serverCallState], ); } let socketAPIHandler: ?SocketAPIHandler = null; function registerActiveSocket(passedSocketAPIHandler: ?SocketAPIHandler) { socketAPIHandler = passedSocketAPIHandler; } export { useDispatchActionPromise, setNewSessionActionType, includeDispatchActionProps, fetchNewCookieFromNativeCredentials, createBoundServerCallsSelector, bindServerCalls, registerActiveSocket, useServerCall, }; diff --git a/lib/utils/config.js b/lib/utils/config.js index dc15cff1b..31ccbac0e 100644 --- a/lib/utils/config.js +++ b/lib/utils/config.js @@ -1,33 +1,34 @@ // @flow -import type { FetchJSON } from './fetch-json'; -import type { DispatchRecoveryAttempt } from './action-utils'; -import type { PlatformDetails } from '../types/device-types'; +import invariant from 'invariant'; + import type { LogInActionSource } from '../types/account-types'; +import type { PlatformDetails } from '../types/device-types'; -import invariant from 'invariant'; +import type { DispatchRecoveryAttempt } from './action-utils'; +import type { FetchJSON } from './fetch-json'; export type Config = { resolveInvalidatedCookie: ?( fetchJSON: FetchJSON, dispatchRecoveryAttempt: DispatchRecoveryAttempt, source?: LogInActionSource, ) => Promise, setCookieOnRequest: boolean, setSessionIDOnRequest: boolean, calendarRangeInactivityLimit: ?number, platformDetails: PlatformDetails, }; let registeredConfig: ?Config = null; const registerConfig = (config: $Shape) => { registeredConfig = { ...registeredConfig, ...config }; }; const getConfig = (): Config => { invariant(registeredConfig, 'config should be set'); return registeredConfig; }; export { registerConfig, getConfig }; diff --git a/lib/utils/errors.js b/lib/utils/errors.js index e70ab2540..52c6f1112 100644 --- a/lib/utils/errors.js +++ b/lib/utils/errors.js @@ -1,66 +1,66 @@ // @flow -import type { PlatformDetails } from '../types/device-types'; - import copyError from 'utils-copy-error'; +import type { PlatformDetails } from '../types/device-types'; + class ExtendableError extends Error { constructor(message: string) { super(message); this.name = this.constructor.name; this.message = message; if (typeof Error.captureStackTrace === 'function') { Error.captureStackTrace(this, this.constructor); } else { this.stack = new Error(message).stack; } } } class ServerError extends ExtendableError { // When specified on server side, will get passed down to client // Only used in updateEntry and deleteEntry currently payload: ?Object; // Used for client_version_unsupported on server-side only platformDetails: ?PlatformDetails; // Used for input validators on server-side only sanitizedInput: mixed; constructor(error: string, payload?: ?Object) { super(error); this.payload = payload; } } class FetchTimeout extends ExtendableError { url: string; constructor(error: string, url: string) { super(error); this.url = url; } } function getMessageForException(e: mixed): ?string { if ( e && typeof e === 'object' && e.message && typeof e.message === 'string' ) { return e.message; } return undefined; } function cloneError(e: E): E { return copyError(e); } export { ExtendableError, ServerError, FetchTimeout, getMessageForException, cloneError, }; diff --git a/lib/utils/fetch-json.js b/lib/utils/fetch-json.js index 98d1bbee3..cafb0c4ea 100644 --- a/lib/utils/fetch-json.js +++ b/lib/utils/fetch-json.js @@ -1,195 +1,195 @@ // @flow +import _cloneDeep from 'lodash/fp/cloneDeep'; + +import { fetchJSONTimeout } from '../shared/timeouts'; +import { SocketOffline, SocketTimeout } from '../socket/inflight-requests'; import { type Endpoint, type SocketAPIHandler, endpointIsSocketPreferred, endpointIsSocketOnly, } from '../types/endpoints'; import type { ServerSessionChange, ClientSessionChange, } from '../types/session-types'; import type { ConnectionStatus } from '../types/socket-types'; import type { CurrentUserInfo } from '../types/user-types'; -import _cloneDeep from 'lodash/fp/cloneDeep'; - -import { ServerError, FetchTimeout } from './errors'; import { getConfig } from './config'; +import { ServerError, FetchTimeout } from './errors'; import sleep from './sleep'; -import { SocketOffline, SocketTimeout } from '../socket/inflight-requests'; import { uploadBlob, type UploadBlob } from './upload-blob'; -import { fetchJSONTimeout } from '../shared/timeouts'; export type FetchJSONOptions = $Shape<{| // null timeout means no timeout, which is the default for uploadBlob timeout: ?number, // in milliseconds blobUpload: boolean | UploadBlob, // the rest (onProgress, abortHandler) only work with blobUpload onProgress: (percent: number) => void, // abortHandler will receive an abort function once the upload starts abortHandler: (abort: () => void) => void, |}>; export type FetchJSONServerResponse = $Shape<{| cookieChange: ServerSessionChange, currentUserInfo?: ?CurrentUserInfo, error: string, payload: Object, |}>; // You'll notice that this is not the type of the fetchJSON function below. This // is because the first several parameters to that functon get bound in by the // helpers in lib/utils/action-utils.js. This type represents the form of the // fetchJSON function that gets passed to the action function in lib/actions. export type FetchJSON = ( endpoint: Endpoint, input: Object, options?: ?FetchJSONOptions, ) => Promise; type RequestData = {| input: { [key: string]: mixed }, cookie?: ?string, sessionID?: ?string, |}; // If cookie is undefined, then we will defer to the underlying environment to // handle cookies, and we won't worry about them. We do this on the web since // our cookies are httponly to protect against XSS attacks. On the other hand, // on native we want to keep track of the cookies since we don't trust the // underlying implementations and prefer for things to be explicit, and XSS // isn't a thing on native. Note that for native, cookie might be null // (indicating we don't have one), and we will then set an empty Cookie header. async function fetchJSON( cookie: ?string, setNewSession: (sessionChange: ClientSessionChange, error: ?string) => void, waitIfCookieInvalidated: () => Promise, cookieInvalidationRecovery: ( sessionChange: ClientSessionChange, ) => Promise, urlPrefix: string, sessionID: ?string, connectionStatus: ConnectionStatus, socketAPIHandler: ?SocketAPIHandler, endpoint: Endpoint, input: { [key: string]: mixed }, options?: ?FetchJSONOptions, ) { const possibleReplacement = await waitIfCookieInvalidated(); if (possibleReplacement) { return await possibleReplacement(endpoint, input, options); } if ( endpointIsSocketPreferred(endpoint) && connectionStatus === 'connected' && socketAPIHandler ) { try { return await socketAPIHandler({ endpoint, input }); } catch (e) { if (endpointIsSocketOnly(endpoint)) { throw e; } else if (e instanceof SocketOffline) { // nothing } else if (e instanceof SocketTimeout) { // nothing } else { throw e; } } } if (endpointIsSocketOnly(endpoint)) { throw new SocketOffline('socket_offline'); } const url = urlPrefix ? `${urlPrefix}/${endpoint}` : endpoint; let json; if (options && options.blobUpload) { const uploadBlobCallback = typeof options.blobUpload === 'function' ? options.blobUpload : uploadBlob; json = await uploadBlobCallback(url, cookie, sessionID, input, options); } else { const mergedData: RequestData = { input }; if (getConfig().setCookieOnRequest) { // We make sure that if setCookieOnRequest is true, we never set cookie to // undefined. null has a special meaning here: we don't currently have a // cookie, and we want the server to specify the new cookie it will generate // in the response body rather than the response header. See // session-types.js for more details on why we specify cookies in the body. mergedData.cookie = cookie ? cookie : null; } if (getConfig().setSessionIDOnRequest) { // We make sure that if setSessionIDOnRequest is true, we never set // sessionID to undefined. null has a special meaning here: we cannot // consider the cookieID to be a unique session identifier, but we do not // have a sessionID to use either. This should only happen when the user is // not logged in on web. mergedData.sessionID = sessionID ? sessionID : null; } const fetchPromise = (async (): Promise => { const response = await fetch(url, { method: 'POST', // This is necessary to allow cookie headers to get passed down to us credentials: 'same-origin', body: JSON.stringify(mergedData), headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', }, }); const text = await response.text(); try { return _cloneDeep(JSON.parse(text)); } catch (e) { console.log(text); throw e; } })(); const timeout = options && options.timeout ? options.timeout : fetchJSONTimeout; if (!timeout) { json = await fetchPromise; } else { const rejectPromise = (async () => { await sleep(timeout); throw new FetchTimeout( `fetchJSON timed out call to ${endpoint}`, endpoint, ); })(); json = await Promise.race([fetchPromise, rejectPromise]); } } const { cookieChange, error, payload, currentUserInfo } = json; const sessionChange: ?ServerSessionChange = cookieChange; if (sessionChange) { const { threadInfos, userInfos, ...rest } = sessionChange; const clientSessionChange = rest.cookieInvalidated ? rest : { cookieInvalidated: false, currentUserInfo, ...rest }; if (clientSessionChange.cookieInvalidated) { const maybeReplacement = await cookieInvalidationRecovery( clientSessionChange, ); if (maybeReplacement) { return await maybeReplacement(endpoint, input, options); } } setNewSession(clientSessionChange, error); } if (error) { throw new ServerError(error, payload); } return json; } export default fetchJSON; diff --git a/lib/utils/objects.js b/lib/utils/objects.js index 69dae2bd3..cd2994358 100644 --- a/lib/utils/objects.js +++ b/lib/utils/objects.js @@ -1,51 +1,51 @@ // @flow -import stringHash from 'string-hash'; import stableStringify from 'fast-json-stable-stringify'; +import stringHash from 'string-hash'; function findMaximumDepth(obj: Object): ?{ path: string, depth: number } { let longestPath = null; let longestDepth = null; for (let key in obj) { const value = obj[key]; if (typeof value !== 'object' || !value) { if (!longestDepth) { longestPath = key; longestDepth = 1; } continue; } const childResult = findMaximumDepth(obj[key]); if (!childResult) { continue; } const { path, depth } = childResult; const ourDepth = depth + 1; if (longestDepth === null || ourDepth > longestDepth) { longestPath = `${key}.${path}`; longestDepth = ourDepth; } } if (!longestPath || !longestDepth) { return null; } return { path: longestPath, depth: longestDepth }; } type Map = { [key: K]: T }; function values(map: Map): T[] { return Object.values ? // https://github.com/facebook/flow/issues/2221 // $FlowFixMe - Object.values currently does not have good flow support Object.values(map) : Object.keys(map).map((key: K): T => map[key]); } function hash(obj: ?Object): number { if (!obj) { return -1; } return stringHash(stableStringify(obj)); } export { findMaximumDepth, values, hash }; diff --git a/lib/utils/redux-utils.js b/lib/utils/redux-utils.js index beb213c1e..3cbec48e0 100644 --- a/lib/utils/redux-utils.js +++ b/lib/utils/redux-utils.js @@ -1,88 +1,88 @@ // @flow -import type { ServerCalls } from './action-utils'; -import type { AppState } from '../types/redux-types'; -import type { ConnectionStatus } from '../types/socket-types'; -import type { CurrentUserInfo } from '../types/user-types'; - +import invariant from 'invariant'; import { connect as reactReduxConnect, useSelector as reactReduxUseSelector, } from 'react-redux'; -import invariant from 'invariant'; -import { includeDispatchActionProps, bindServerCalls } from './action-utils'; import { serverCallStateSelector } from '../selectors/server-calls'; +import type { AppState } from '../types/redux-types'; +import type { ConnectionStatus } from '../types/socket-types'; +import type { CurrentUserInfo } from '../types/user-types'; + +import type { ServerCalls } from './action-utils'; +import { includeDispatchActionProps, bindServerCalls } from './action-utils'; function connect( inputMapStateToProps: ?(state: S, ownProps: OP) => SP, serverCalls?: ?ServerCalls, includeDispatch?: boolean, ): * { const mapStateToProps = inputMapStateToProps; const serverCallExists = serverCalls && Object.keys(serverCalls).length > 0; let mapState = null; if (serverCallExists && mapStateToProps && mapStateToProps.length > 1) { mapState = ( state: S, ownProps: OP, ): { ...SP, cookie: ?string, urlPrefix: string, sessionID: ?string, currentUserInfo: ?CurrentUserInfo, connectionStatus: ConnectionStatus, } => ({ ...mapStateToProps(state, ownProps), ...serverCallStateSelector(state), }); } else if (serverCallExists && mapStateToProps) { mapState = ( state: S, ): { ...SP, cookie: ?string, urlPrefix: string, sessionID: ?string, currentUserInfo: ?CurrentUserInfo, connectionStatus: ConnectionStatus, } => ({ // $FlowFixMe ...mapStateToProps(state), ...serverCallStateSelector(state), }); } else if (mapStateToProps) { mapState = mapStateToProps; } else if (serverCallExists) { mapState = serverCallStateSelector; } const dispatchIncluded = includeDispatch === true || (includeDispatch === undefined && serverCallExists); if (dispatchIncluded && serverCallExists) { invariant(mapState && serverCalls, 'should be set'); return reactReduxConnect( mapState, includeDispatchActionProps, bindServerCalls(serverCalls), ); } else if (dispatchIncluded) { return reactReduxConnect(mapState, includeDispatchActionProps); } else if (serverCallExists) { invariant(mapState && serverCalls, 'should be set'); return reactReduxConnect(mapState, undefined, bindServerCalls(serverCalls)); } else { invariant(mapState, 'should be set'); return reactReduxConnect(mapState); } } function useSelector( selector: (state: AppState) => SS, equalityFn?: (a: SS, b: SS) => boolean, ): SS { return reactReduxUseSelector(selector, equalityFn); } export { connect, useSelector }; diff --git a/lib/utils/sanitization.js b/lib/utils/sanitization.js index 8e2adb5c2..72b96e9b9 100644 --- a/lib/utils/sanitization.js +++ b/lib/utils/sanitization.js @@ -1,61 +1,62 @@ // @flow +import { setDeviceTokenActionTypes } from '../actions/device-actions'; import type { BaseAction, NativeAppState, AppState, } from '../types/redux-types'; + import { setNewSessionActionType } from './action-utils'; -import { setDeviceTokenActionTypes } from '../actions/device-actions'; function sanitizeAction(action: BaseAction): BaseAction { if (action.type === setNewSessionActionType) { const { sessionChange } = action.payload; if (sessionChange.cookieInvalidated) { const { cookie, ...rest } = sessionChange; return { type: 'SET_NEW_SESSION', payload: { ...action.payload, sessionChange: { cookieInvalidated: true, ...rest }, }, }; } else { const { cookie, ...rest } = sessionChange; return { type: 'SET_NEW_SESSION', payload: { ...action.payload, sessionChange: { cookieInvalidated: false, ...rest }, }, }; } } else if (action.type === setDeviceTokenActionTypes.started) { return { type: 'SET_DEVICE_TOKEN_STARTED', payload: 'FAKE', loadingInfo: action.loadingInfo, }; } else if (action.type === setDeviceTokenActionTypes.success) { return { type: 'SET_DEVICE_TOKEN_SUCCESS', payload: 'FAKE', loadingInfo: action.loadingInfo, }; } return action; } function sanitizeState(state: AppState): AppState { if (state.cookie !== undefined && state.cookie !== null) { const oldState: NativeAppState = state; state = { ...oldState, cookie: null }; } if (state.deviceToken !== undefined && state.deviceToken !== null) { const oldState: NativeAppState = state; state = { ...oldState, deviceToken: null }; } return state; } export { sanitizeAction, sanitizeState }; diff --git a/lib/utils/upload-blob.js b/lib/utils/upload-blob.js index edc5b8ba4..0916bde74 100644 --- a/lib/utils/upload-blob.js +++ b/lib/utils/upload-blob.js @@ -1,120 +1,119 @@ // @flow -import type { FetchJSONOptions, FetchJSONServerResponse } from './fetch-json'; - import invariant from 'invariant'; import _cloneDeep from 'lodash/fp/cloneDeep'; import _throttle from 'lodash/throttle'; import { getConfig } from './config'; +import type { FetchJSONOptions, FetchJSONServerResponse } from './fetch-json'; function uploadBlob( url: string, cookie: ?string, sessionID: ?string, input: { [key: string]: mixed }, options?: ?FetchJSONOptions, ): Promise { const formData = new FormData(); if (getConfig().setCookieOnRequest) { // We make sure that if setCookieOnRequest is true, we never set cookie to // undefined. null has a special meaning here: we don't currently have a // cookie, and we want the server to specify the new cookie it will generate // in the response body rather than the response header. See // session-types.js for more details on why we specify cookies in the body. formData.append('cookie', cookie ? cookie : ''); } if (getConfig().setSessionIDOnRequest) { // We make sure that if setSessionIDOnRequest is true, we never set // sessionID to undefined. null has a special meaning here: we cannot // consider the cookieID to be a unique session identifier, but we do not // have a sessionID to use either. This should only happen when the user is // not logged in on web. formData.append('sessionID', sessionID ? sessionID : ''); } for (let key in input) { if (key === 'multimedia' || key === 'cookie' || key === 'sessionID') { continue; } const value = input[key]; invariant( typeof value === 'string', 'blobUpload calls can only handle string values for non-multimedia keys', ); formData.append(key, value); } const { multimedia } = input; if (multimedia && Array.isArray(multimedia)) { for (let media of multimedia) { // We perform an any-cast here because of React Native. Though Blob // support was introduced in react-native@0.54, it isn't compatible with // FormData. Instead, React Native requires a specific object format. formData.append('multimedia', (media: any)); } } const xhr = new XMLHttpRequest(); xhr.open('POST', url); xhr.withCredentials = true; xhr.setRequestHeader('Accept', 'application/json'); if (options && options.timeout) { xhr.timeout = options.timeout; } if (options && options.onProgress) { const { onProgress } = options; xhr.upload.onprogress = _throttle( ({ loaded, total }) => onProgress(loaded / total), 50, ); } let failed = false; const responsePromise = new Promise((resolve, reject) => { xhr.onload = () => { if (failed) { return; } const text = xhr.responseText; try { resolve(_cloneDeep(JSON.parse(text))); } catch (e) { console.log(text); reject(e); } }; xhr.onabort = () => { failed = true; reject(new Error('request aborted')); }; xhr.onerror = (event) => { failed = true; reject(event); }; if (options && options.timeout) { xhr.ontimeout = (event) => { failed = true; reject(event); }; } if (options && options.abortHandler) { options.abortHandler(() => { failed = true; reject(new Error('request aborted')); xhr.abort(); }); } }); if (!failed) { xhr.send(formData); } return responsePromise; } export type UploadBlob = typeof uploadBlob; export { uploadBlob }; diff --git a/native/__tests__/test.js b/native/__tests__/test.js index 21c7a2ff8..348e28e84 100644 --- a/native/__tests__/test.js +++ b/native/__tests__/test.js @@ -1,12 +1,13 @@ // @flow import 'react-native'; import * as React from 'react'; +import renderer from 'react-test-renderer'; + import Root from '../root.react'; // Note: test renderer must be required after react-native. -import renderer from 'react-test-renderer'; it('renders correctly', () => { renderer.create(); }); diff --git a/native/account/forgot-password-panel.react.js b/native/account/forgot-password-panel.react.js index 675c1a770..d6a3f01e9 100644 --- a/native/account/forgot-password-panel.react.js +++ b/native/account/forgot-password-panel.react.js @@ -1,182 +1,181 @@ // @flow -import type { AppState } from '../redux/redux-setup'; -import type { LoadingStatus } from 'lib/types/loading-types'; -import type { DispatchActionPromise } from 'lib/utils/action-utils'; - -import React from 'react'; -import { StyleSheet, View, Alert, Keyboard } from 'react-native'; -import Icon from 'react-native-vector-icons/FontAwesome'; import invariant from 'invariant'; -import PropTypes from 'prop-types'; -import Animated from 'react-native-reanimated'; - -import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { forgotPasswordActionTypes, forgotPassword, } from 'lib/actions/user-actions'; -import { connect } from 'lib/utils/redux-utils'; +import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { oldValidUsernameRegex, validEmailRegex, } from 'lib/shared/account-utils'; +import type { LoadingStatus } from 'lib/types/loading-types'; +import type { DispatchActionPromise } from 'lib/utils/action-utils'; +import { connect } from 'lib/utils/redux-utils'; +import PropTypes from 'prop-types'; +import React from 'react'; +import { StyleSheet, View, Alert, Keyboard } from 'react-native'; +import Animated from 'react-native-reanimated'; +import Icon from 'react-native-vector-icons/FontAwesome'; + +import type { AppState } from '../redux/redux-setup'; import { TextInput, usernamePlaceholderSelector, } from './modal-components.react'; import { PanelButton, Panel } from './panel-components.react'; type Props = {| +setActiveAlert: (activeAlert: boolean) => void, +opacityValue: Animated.Value, +onSuccess: () => void, // Redux state +loadingStatus: LoadingStatus, +usernamePlaceholder: string, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +forgotPassword: (usernameOrEmail: string) => Promise, |}; type State = {| +usernameOrEmailInputText: string, |}; class ForgotPasswordPanel extends React.PureComponent { static propTypes = { setActiveAlert: PropTypes.func.isRequired, opacityValue: PropTypes.object.isRequired, onSuccess: PropTypes.func.isRequired, loadingStatus: PropTypes.string.isRequired, usernamePlaceholder: PropTypes.string.isRequired, dispatchActionPromise: PropTypes.func.isRequired, forgotPassword: PropTypes.func.isRequired, }; state: State = { usernameOrEmailInputText: '', }; usernameOrEmailInput: ?TextInput; render() { return ( ); } usernameOrEmailInputRef = (usernameOrEmailInput: ?TextInput) => { this.usernameOrEmailInput = usernameOrEmailInput; }; onChangeUsernameOrEmailInputText = (text: string) => { this.setState({ usernameOrEmailInputText: text }); }; onSubmit = () => { this.props.setActiveAlert(true); if ( this.state.usernameOrEmailInputText.search(oldValidUsernameRegex) === -1 && this.state.usernameOrEmailInputText.search(validEmailRegex) === -1 ) { Alert.alert( 'Invalid username', 'Alphanumeric usernames or emails only', [{ text: 'OK', onPress: this.onUsernameOrEmailAlertAcknowledged }], { cancelable: false }, ); return; } Keyboard.dismiss(); this.props.dispatchActionPromise( forgotPasswordActionTypes, this.forgotPasswordAction(), ); }; onUsernameOrEmailAlertAcknowledged = () => { this.props.setActiveAlert(false); this.setState( { usernameOrEmailInputText: '', }, () => { invariant(this.usernameOrEmailInput, 'ref should exist'); this.usernameOrEmailInput.focus(); }, ); }; async forgotPasswordAction() { try { await this.props.forgotPassword(this.state.usernameOrEmailInputText); this.props.setActiveAlert(false); this.props.onSuccess(); } catch (e) { if (e.message === 'invalid_user') { Alert.alert( "User doesn't exist", 'No user with that username or email exists', [{ text: 'OK', onPress: this.onUsernameOrEmailAlertAcknowledged }], { cancelable: false }, ); } else { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: this.onUsernameOrEmailAlertAcknowledged }], { cancelable: false }, ); } throw e; } } } const styles = StyleSheet.create({ icon: { bottom: 8, left: 4, position: 'absolute', }, input: { paddingLeft: 35, }, }); const loadingStatusSelector = createLoadingStatusSelector( forgotPasswordActionTypes, ); export default connect( (state: AppState) => ({ loadingStatus: loadingStatusSelector(state), usernamePlaceholder: usernamePlaceholderSelector(state), }), { forgotPassword }, )(ForgotPasswordPanel); diff --git a/native/account/log-in-panel-container.react.js b/native/account/log-in-panel-container.react.js index 24a8ccc2a..4a73300f8 100644 --- a/native/account/log-in-panel-container.react.js +++ b/native/account/log-in-panel-container.react.js @@ -1,277 +1,276 @@ // @flow -import type { InnerLogInPanel, LogInState } from './log-in-panel.react'; -import { - type StateContainer, - stateContainerPropType, -} from '../utils/state-container'; -import type { AppState } from '../redux/redux-setup'; - -import * as React from 'react'; -import { View, Text, StyleSheet } from 'react-native'; -import Icon from 'react-native-vector-icons/FontAwesome'; import invariant from 'invariant'; +import { connect } from 'lib/utils/redux-utils'; +import sleep from 'lib/utils/sleep'; import PropTypes from 'prop-types'; +import * as React from 'react'; +import { View, Text, StyleSheet } from 'react-native'; import Animated from 'react-native-reanimated'; +import Icon from 'react-native-vector-icons/FontAwesome'; -import sleep from 'lib/utils/sleep'; -import { connect } from 'lib/utils/redux-utils'; +import type { AppState } from '../redux/redux-setup'; +import { runTiming } from '../utils/animation-utils'; +import { + type StateContainer, + stateContainerPropType, +} from '../utils/state-container'; -import LogInPanel from './log-in-panel.react'; import ForgotPasswordPanel from './forgot-password-panel.react'; -import { runTiming } from '../utils/animation-utils'; +import LogInPanel from './log-in-panel.react'; +import type { InnerLogInPanel, LogInState } from './log-in-panel.react'; type LogInMode = 'log-in' | 'forgot-password' | 'forgot-password-success'; const modeNumbers: { [LogInMode]: number } = { 'log-in': 0, 'forgot-password': 1, 'forgot-password-success': 2, }; /* eslint-disable import/no-named-as-default-member */ const { Value, Clock, block, set, call, cond, eq, neq, lessThan, modulo, stopClock, interpolate, } = Animated; /* eslint-enable import/no-named-as-default-member */ type Props = {| setActiveAlert: (activeAlert: boolean) => void, opacityValue: Value, hideForgotPasswordLink: Value, logInState: StateContainer, innerRef: (container: ?LogInPanelContainer) => void, // Redux state windowWidth: number, |}; type State = {| logInMode: LogInMode, nextLogInMode: LogInMode, |}; class LogInPanelContainer extends React.PureComponent { static propTypes = { setActiveAlert: PropTypes.func.isRequired, opacityValue: PropTypes.object.isRequired, hideForgotPasswordLink: PropTypes.instanceOf(Value).isRequired, logInState: stateContainerPropType.isRequired, innerRef: PropTypes.func.isRequired, windowWidth: PropTypes.number.isRequired, }; logInPanel: ?InnerLogInPanel = null; panelTransitionTarget: Value; panelTransitionValue: Value; constructor(props: Props) { super(props); this.state = { logInMode: 'log-in', nextLogInMode: 'log-in', }; this.panelTransitionTarget = new Value(modeNumbers['log-in']); this.panelTransitionValue = this.panelTransition(); } proceedToNextMode = () => { this.setState({ logInMode: this.state.nextLogInMode }); }; panelTransition() { const panelTransition = new Value(-1); const prevPanelTransitionTarget = new Value(-1); const clock = new Clock(); return block([ cond(lessThan(panelTransition, 0), [ set(panelTransition, this.panelTransitionTarget), set(prevPanelTransitionTarget, this.panelTransitionTarget), ]), cond(neq(this.panelTransitionTarget, prevPanelTransitionTarget), [ stopClock(clock), set(prevPanelTransitionTarget, this.panelTransitionTarget), ]), cond( neq(panelTransition, this.panelTransitionTarget), set( panelTransition, runTiming(clock, panelTransition, this.panelTransitionTarget), ), ), cond(eq(modulo(panelTransition, 1), 0), call([], this.proceedToNextMode)), panelTransition, ]); } componentDidMount() { this.props.innerRef(this); } componentWillUnmount() { this.props.innerRef(null); } render() { const { windowWidth } = this.props; const logInPanelDynamicStyle = { left: interpolate(this.panelTransitionValue, { inputRange: [0, 2], outputRange: [0, windowWidth * -2], }), right: interpolate(this.panelTransitionValue, { inputRange: [0, 2], outputRange: [0, windowWidth * 2], }), }; const logInPanel = ( ); let forgotPasswordPanel = null; if ( this.state.nextLogInMode !== 'log-in' || this.state.logInMode !== 'log-in' ) { const forgotPasswordPanelDynamicStyle = { left: interpolate(this.panelTransitionValue, { inputRange: [0, 2], outputRange: [windowWidth, windowWidth * -1], }), right: interpolate(this.panelTransitionValue, { inputRange: [0, 2], outputRange: [windowWidth * -1, windowWidth], }), }; forgotPasswordPanel = ( ); } let forgotPasswordSuccess = null; if ( this.state.nextLogInMode === 'forgot-password-success' || this.state.logInMode === 'forgot-password-success' ) { const forgotPasswordSuccessDynamicStyle = { left: interpolate(this.panelTransitionValue, { inputRange: [0, 2], outputRange: [windowWidth * 2, 0], }), right: interpolate(this.panelTransitionValue, { inputRange: [0, 2], outputRange: [windowWidth * -2, 0], }), }; const successText = "Okay, we've sent that account an email. Check your inbox to " + 'complete the process.'; forgotPasswordSuccess = ( {successText} ); } return ( {logInPanel} {forgotPasswordPanel} {forgotPasswordSuccess} ); } logInPanelRef = (logInPanel: ?InnerLogInPanel) => { this.logInPanel = logInPanel; }; onPressForgotPassword = () => { this.props.hideForgotPasswordLink.setValue(1); this.setState({ nextLogInMode: 'forgot-password' }); this.panelTransitionTarget.setValue(modeNumbers['forgot-password']); }; backFromLogInMode = () => { if (this.state.nextLogInMode === 'log-in') { return false; } this.setState({ logInMode: this.state.nextLogInMode, nextLogInMode: 'log-in', }); invariant(this.logInPanel, 'ref should be set'); this.logInPanel.focusUsernameOrEmailInput(); this.props.hideForgotPasswordLink.setValue(0); this.panelTransitionTarget.setValue(modeNumbers['log-in']); return true; }; onForgotPasswordSuccess = () => { if (this.state.nextLogInMode === 'log-in') { return; } this.setState({ nextLogInMode: 'forgot-password-success' }); this.panelTransitionTarget.setValue(modeNumbers['forgot-password-success']); this.inCoupleSecondsNavigateToLogIn(); }; async inCoupleSecondsNavigateToLogIn() { await sleep(2350); this.backFromLogInMode(); } } const styles = StyleSheet.create({ forgotPasswordSuccessIcon: { marginTop: 40, textAlign: 'center', }, forgotPasswordSuccessText: { color: 'white', fontSize: 18, marginLeft: 20, marginRight: 20, marginTop: 10, textAlign: 'center', }, panel: { left: 0, position: 'absolute', right: 0, }, }); export default connect((state: AppState) => ({ windowWidth: state.dimensions.width, }))(LogInPanelContainer); diff --git a/native/account/log-in-panel.react.js b/native/account/log-in-panel.react.js index 804c373f2..52c5e367f 100644 --- a/native/account/log-in-panel.react.js +++ b/native/account/log-in-panel.react.js @@ -1,356 +1,355 @@ // @flow -import type { LoadingStatus } from 'lib/types/loading-types'; +import invariant from 'invariant'; +import { logInActionTypes, logIn } from 'lib/actions/user-actions'; +import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; +import { + oldValidUsernameRegex, + validEmailRegex, +} from 'lib/shared/account-utils'; import type { LogInInfo, LogInExtraInfo, LogInResult, LogInStartingPayload, } from 'lib/types/account-types'; -import type { StateContainer } from '../utils/state-container'; - -import * as React from 'react'; -import { View, StyleSheet, Alert, Keyboard, Platform } from 'react-native'; -import Icon from 'react-native-vector-icons/FontAwesome'; -import invariant from 'invariant'; -import Animated from 'react-native-reanimated'; +import type { LoadingStatus } from 'lib/types/loading-types'; import { useServerCall, useDispatchActionPromise, type DispatchActionPromise, } from 'lib/utils/action-utils'; +import * as React from 'react'; +import { View, StyleSheet, Alert, Keyboard, Platform } from 'react-native'; +import Animated from 'react-native-reanimated'; +import Icon from 'react-native-vector-icons/FontAwesome'; -import { - oldValidUsernameRegex, - validEmailRegex, -} from 'lib/shared/account-utils'; -import { logInActionTypes, logIn } from 'lib/actions/user-actions'; -import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; +import { NavContext } from '../navigation/navigation-context'; +import { useSelector } from '../redux/redux-utils'; +import { nativeLogInExtraInfoSelector } from '../selectors/account-selectors'; +import type { StateContainer } from '../utils/state-container'; import { TextInput, usernamePlaceholderSelector, } from './modal-components.react'; -import { PanelButton, Panel } from './panel-components.react'; import { fetchNativeCredentials, setNativeCredentials, } from './native-credentials'; -import { nativeLogInExtraInfoSelector } from '../selectors/account-selectors'; -import { NavContext } from '../navigation/navigation-context'; -import { useSelector } from '../redux/redux-utils'; +import { PanelButton, Panel } from './panel-components.react'; export type LogInState = {| +usernameOrEmailInputText: string, +passwordInputText: string, |}; type BaseProps = {| +setActiveAlert: (activeAlert: boolean) => void, +opacityValue: Animated.Value, +innerRef: (logInPanel: ?LogInPanel) => void, +state: StateContainer, |}; type Props = {| ...BaseProps, // Redux state +loadingStatus: LoadingStatus, +logInExtraInfo: () => LogInExtraInfo, +usernamePlaceholder: string, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +logIn: (logInInfo: LogInInfo) => Promise, |}; class LogInPanel extends React.PureComponent { usernameOrEmailInput: ?TextInput; passwordInput: ?TextInput; componentDidMount() { this.props.innerRef(this); this.attemptToFetchCredentials(); } componentWillUnmount() { this.props.innerRef(null); } async attemptToFetchCredentials() { const credentials = await fetchNativeCredentials(); if (credentials) { this.props.state.setState({ usernameOrEmailInputText: credentials.username, passwordInputText: credentials.password, }); } } render() { return ( ); } usernameOrEmailInputRef = (usernameOrEmailInput: ?TextInput) => { this.usernameOrEmailInput = usernameOrEmailInput; if (Platform.OS === 'ios' && usernameOrEmailInput) { setTimeout(() => usernameOrEmailInput.focus()); } }; focusUsernameOrEmailInput = () => { invariant(this.usernameOrEmailInput, 'ref should be set'); this.usernameOrEmailInput.focus(); }; passwordInputRef = (passwordInput: ?TextInput) => { this.passwordInput = passwordInput; }; focusPasswordInput = () => { invariant(this.passwordInput, 'ref should be set'); this.passwordInput.focus(); }; onChangeUsernameOrEmailInputText = (text: string) => { this.props.state.setState({ usernameOrEmailInputText: text }); }; onUsernameOrEmailKeyPress = ( event: $ReadOnly<{ nativeEvent: $ReadOnly<{ key: string }> }>, ) => { const { key } = event.nativeEvent; if ( key.length > 1 && key !== 'Backspace' && key !== 'Enter' && this.props.state.state.passwordInputText.length === 0 ) { this.focusPasswordInput(); } }; onChangePasswordInputText = (text: string) => { this.props.state.setState({ passwordInputText: text }); }; onSubmit = () => { this.props.setActiveAlert(true); if ( this.props.state.state.usernameOrEmailInputText.search( oldValidUsernameRegex, ) === -1 && this.props.state.state.usernameOrEmailInputText.search( validEmailRegex, ) === -1 ) { Alert.alert( 'Invalid username', 'Alphanumeric usernames or emails only', [{ text: 'OK', onPress: this.onUsernameOrEmailAlertAcknowledged }], { cancelable: false }, ); return; } else if (this.props.state.state.passwordInputText === '') { Alert.alert( 'Empty password', 'Password cannot be empty', [{ text: 'OK', onPress: this.onPasswordAlertAcknowledged }], { cancelable: false }, ); return; } Keyboard.dismiss(); const extraInfo = this.props.logInExtraInfo(); this.props.dispatchActionPromise( logInActionTypes, this.logInAction(extraInfo), undefined, ({ calendarQuery: extraInfo.calendarQuery }: LogInStartingPayload), ); }; onUsernameOrEmailAlertAcknowledged = () => { this.props.setActiveAlert(false); this.props.state.setState( { usernameOrEmailInputText: '', }, () => { invariant(this.usernameOrEmailInput, 'ref should exist'); this.usernameOrEmailInput.focus(); }, ); }; async logInAction(extraInfo: LogInExtraInfo) { try { const result = await this.props.logIn({ usernameOrEmail: this.props.state.state.usernameOrEmailInputText, password: this.props.state.state.passwordInputText, ...extraInfo, }); this.props.setActiveAlert(false); await setNativeCredentials({ username: result.currentUserInfo.username, password: this.props.state.state.passwordInputText, }); return result; } catch (e) { if (e.message === 'invalid_parameters') { Alert.alert( 'Invalid username', "User doesn't exist", [{ text: 'OK', onPress: this.onUsernameOrEmailAlertAcknowledged }], { cancelable: false }, ); } else if (e.message === 'invalid_credentials') { Alert.alert( 'Incorrect password', 'The password you entered is incorrect', [{ text: 'OK', onPress: this.onPasswordAlertAcknowledged }], { cancelable: false }, ); } else if (e.message === 'client_version_unsupported') { const app = Platform.select({ ios: 'Testflight', android: 'Play Store', }); Alert.alert( 'App out of date', "Your app version is pretty old, and the server doesn't know how " + `to speak to it anymore. Please use the ${app} app to update!`, [{ text: 'OK', onPress: this.onAppOutOfDateAlertAcknowledged }], { cancelable: false }, ); } else { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: this.onUnknownErrorAlertAcknowledged }], { cancelable: false }, ); } throw e; } } onPasswordAlertAcknowledged = () => { this.props.setActiveAlert(false); this.props.state.setState( { passwordInputText: '', }, () => { invariant(this.passwordInput, 'passwordInput ref unset'); this.passwordInput.focus(); }, ); }; onUnknownErrorAlertAcknowledged = () => { this.props.setActiveAlert(false); this.props.state.setState( { usernameOrEmailInputText: '', passwordInputText: '', }, () => { invariant(this.usernameOrEmailInput, 'ref should exist'); this.usernameOrEmailInput.focus(); }, ); }; onAppOutOfDateAlertAcknowledged = () => { this.props.setActiveAlert(false); }; } export type InnerLogInPanel = LogInPanel; const styles = StyleSheet.create({ icon: { bottom: 8, left: 4, position: 'absolute', }, input: { paddingLeft: 35, }, }); const loadingStatusSelector = createLoadingStatusSelector(logInActionTypes); export default React.memo(function ConnectedLogInPanel( props: BaseProps, ) { const loadingStatus = useSelector(loadingStatusSelector); const usernamePlaceholder = useSelector(usernamePlaceholderSelector); const navContext = React.useContext(NavContext); const logInExtraInfo = useSelector((state) => nativeLogInExtraInfoSelector({ redux: state, navContext, }), ); const dispatchActionPromise = useDispatchActionPromise(); const callLogIn = useServerCall(logIn); return ( ); }); diff --git a/native/account/logged-out-modal.react.js b/native/account/logged-out-modal.react.js index a2c064252..de05fc838 100644 --- a/native/account/logged-out-modal.react.js +++ b/native/account/logged-out-modal.react.js @@ -1,808 +1,807 @@ // @flow +import invariant from 'invariant'; +import { + appStartNativeCredentialsAutoLogIn, + appStartReduxLoggedInButInvalidCookie, +} from 'lib/actions/user-actions'; +import { isLoggedIn } from 'lib/selectors/user-selectors'; import type { Dispatch } from 'lib/types/redux-types'; -import type { KeyboardEvent, EmitterSubscription } from '../keyboard/keyboard'; -import type { LogInState } from './log-in-panel.react'; -import type { RegisterState } from './register-panel.react'; -import type { ImageStyle } from '../types/styles'; - +import { fetchNewCookieFromNativeCredentials } from 'lib/utils/action-utils'; +import _isEqual from 'lodash/fp/isEqual'; import * as React from 'react'; import { View, StyleSheet, Text, TouchableOpacity, Image, Keyboard, Platform, BackHandler, ActivityIndicator, } from 'react-native'; -import invariant from 'invariant'; -import Icon from 'react-native-vector-icons/FontAwesome'; -import _isEqual from 'lodash/fp/isEqual'; -import { SafeAreaView } from 'react-native-safe-area-context'; import Animated, { Easing } from 'react-native-reanimated'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import Icon from 'react-native-vector-icons/FontAwesome'; import { useDispatch } from 'react-redux'; -import { fetchNewCookieFromNativeCredentials } from 'lib/utils/action-utils'; -import { - appStartNativeCredentialsAutoLogIn, - appStartReduxLoggedInButInvalidCookie, -} from 'lib/actions/user-actions'; -import { isLoggedIn } from 'lib/selectors/user-selectors'; - -import LogInPanelContainer from './log-in-panel-container.react'; -import RegisterPanel from './register-panel.react'; import ConnectedStatusBar from '../connected-status-bar.react'; -import { createIsForegroundSelector } from '../navigation/nav-selectors'; -import { resetUserStateActionType } from '../redux/action-types'; -import { splashBackgroundURI } from './background-info'; -import { splashStyleSelector } from '../splash'; +import type { KeyboardEvent, EmitterSubscription } from '../keyboard/keyboard'; import { addKeyboardShowListener, addKeyboardDismissListener, removeKeyboardListener, } from '../keyboard/keyboard'; -import { - type StateContainer, - type StateChange, - setStateForContainer, -} from '../utils/state-container'; -import { LoggedOutModalRouteName } from '../navigation/route-names'; +import { createIsForegroundSelector } from '../navigation/nav-selectors'; import { NavContext } from '../navigation/navigation-context'; +import { LoggedOutModalRouteName } from '../navigation/route-names'; +import { resetUserStateActionType } from '../redux/action-types'; +import { useSelector } from '../redux/redux-utils'; +import { + type DerivedDimensionsInfo, + derivedDimensionsInfoSelector, +} from '../selectors/dimensions-selectors'; +import { splashStyleSelector } from '../splash'; +import type { ImageStyle } from '../types/styles'; import { runTiming, ratchetAlongWithKeyboardHeight, } from '../utils/animation-utils'; import { - type DerivedDimensionsInfo, - derivedDimensionsInfoSelector, -} from '../selectors/dimensions-selectors'; -import { useSelector } from '../redux/redux-utils'; + type StateContainer, + type StateChange, + setStateForContainer, +} from '../utils/state-container'; + +import { splashBackgroundURI } from './background-info'; +import LogInPanelContainer from './log-in-panel-container.react'; +import type { LogInState } from './log-in-panel.react'; +import RegisterPanel from './register-panel.react'; +import type { RegisterState } from './register-panel.react'; let initialAppLoad = true; const safeAreaEdges = ['top', 'bottom']; /* eslint-disable import/no-named-as-default-member */ const { Value, Clock, block, set, call, cond, not, and, eq, neq, lessThan, greaterOrEq, add, sub, divide, max, stopClock, clockRunning, } = Animated; /* eslint-enable import/no-named-as-default-member */ type LoggedOutMode = 'loading' | 'prompt' | 'log-in' | 'register'; const modeNumbers: { [LoggedOutMode]: number } = { 'loading': 0, 'prompt': 1, 'log-in': 2, 'register': 3, }; function isPastPrompt(modeValue: Animated.Node) { return and( neq(modeValue, modeNumbers['loading']), neq(modeValue, modeNumbers['prompt']), ); } type Props = { // Navigation state +isForeground: boolean, // Redux state +rehydrateConcluded: boolean, +cookie: ?string, +urlPrefix: string, +loggedIn: boolean, +dimensions: DerivedDimensionsInfo, +splashStyle: ImageStyle, // Redux dispatch functions +dispatch: Dispatch, ... }; type State = {| +mode: LoggedOutMode, +logInState: StateContainer, +registerState: StateContainer, |}; class LoggedOutModal extends React.PureComponent { keyboardShowListener: ?EmitterSubscription; keyboardHideListener: ?EmitterSubscription; mounted = false; nextMode: LoggedOutMode = 'loading'; activeAlert = false; logInPanelContainer: ?LogInPanelContainer = null; contentHeight: Value; keyboardHeightValue = new Value(0); modeValue: Value; hideForgotPasswordLink = new Value(0); buttonOpacity: Value; panelPaddingTopValue: Value; footerPaddingTopValue: Value; panelOpacityValue: Value; forgotPasswordLinkOpacityValue: Value; constructor(props: Props) { super(props); // Man, this is a lot of boilerplate just to containerize some state. // Mostly due to Flow typing requirements... const setLogInState = setStateForContainer( this.guardedSetState, (change: $Shape) => (fullState: State) => ({ logInState: { ...fullState.logInState, state: { ...fullState.logInState.state, ...change }, }, }), ); const setRegisterState = setStateForContainer( this.guardedSetState, (change: $Shape) => (fullState: State) => ({ registerState: { ...fullState.registerState, state: { ...fullState.registerState.state, ...change }, }, }), ); this.state = { mode: props.rehydrateConcluded ? 'prompt' : 'loading', logInState: { state: { usernameOrEmailInputText: '', passwordInputText: '', }, setState: setLogInState, }, registerState: { state: { usernameInputText: '', emailInputText: '', passwordInputText: '', confirmPasswordInputText: '', }, setState: setRegisterState, }, }; if (props.rehydrateConcluded) { this.nextMode = 'prompt'; } this.contentHeight = new Value(props.dimensions.safeAreaHeight); this.modeValue = new Value(modeNumbers[this.nextMode]); this.buttonOpacity = new Value(props.rehydrateConcluded ? 1 : 0); this.panelPaddingTopValue = this.panelPaddingTop(); this.footerPaddingTopValue = this.footerPaddingTop(); this.panelOpacityValue = this.panelOpacity(); this.forgotPasswordLinkOpacityValue = this.forgotPasswordLinkOpacity(); } guardedSetState = (change: StateChange, callback?: () => mixed) => { if (this.mounted) { this.setState(change, callback); } }; setMode(newMode: LoggedOutMode) { this.nextMode = newMode; this.guardedSetState({ mode: newMode }); this.modeValue.setValue(modeNumbers[newMode]); } proceedToNextMode = () => { this.guardedSetState({ mode: this.nextMode }); }; componentDidMount() { this.mounted = true; if (this.props.rehydrateConcluded) { this.onInitialAppLoad(); } if (this.props.isForeground) { this.onForeground(); } } componentWillUnmount() { this.mounted = false; if (this.props.isForeground) { this.onBackground(); } } componentDidUpdate(prevProps: Props, prevState: State) { if (!prevProps.rehydrateConcluded && this.props.rehydrateConcluded) { this.setMode('prompt'); this.onInitialAppLoad(); } if (!prevProps.isForeground && this.props.isForeground) { this.onForeground(); } else if (prevProps.isForeground && !this.props.isForeground) { this.onBackground(); } if (this.state.mode === 'prompt' && prevState.mode !== 'prompt') { this.buttonOpacity.setValue(0); Animated.timing(this.buttonOpacity, { easing: Easing.out(Easing.ease), duration: 250, toValue: 1.0, }).start(); } const newContentHeight = this.props.dimensions.safeAreaHeight; const oldContentHeight = prevProps.dimensions.safeAreaHeight; if (newContentHeight !== oldContentHeight) { this.contentHeight.setValue(newContentHeight); } } onForeground() { this.keyboardShowListener = addKeyboardShowListener(this.keyboardShow); this.keyboardHideListener = addKeyboardDismissListener(this.keyboardHide); BackHandler.addEventListener('hardwareBackPress', this.hardwareBack); } onBackground() { if (this.keyboardShowListener) { removeKeyboardListener(this.keyboardShowListener); this.keyboardShowListener = null; } if (this.keyboardHideListener) { removeKeyboardListener(this.keyboardHideListener); this.keyboardHideListener = null; } BackHandler.removeEventListener('hardwareBackPress', this.hardwareBack); } // This gets triggered when an app is killed and restarted // Not when it is returned from being backgrounded async onInitialAppLoad() { if (!initialAppLoad) { return; } initialAppLoad = false; const { loggedIn, cookie, urlPrefix, dispatch } = this.props; const hasUserCookie = cookie && cookie.startsWith('user='); if (loggedIn && hasUserCookie) { return; } if (!__DEV__) { const actionSource = loggedIn ? appStartReduxLoggedInButInvalidCookie : appStartNativeCredentialsAutoLogIn; const sessionChange = await fetchNewCookieFromNativeCredentials( dispatch, cookie, urlPrefix, actionSource, ); if ( sessionChange && sessionChange.cookie && sessionChange.cookie.startsWith('user=') ) { // success! we can expect subsequent actions to fix up the state return; } } if (loggedIn || hasUserCookie) { this.props.dispatch({ type: resetUserStateActionType, payload: null, }); } } hardwareBack = () => { if (this.nextMode === 'log-in') { invariant(this.logInPanelContainer, 'ref should be set'); const returnValue = this.logInPanelContainer.backFromLogInMode(); if (returnValue) { return true; } } if (this.nextMode !== 'prompt') { this.goBackToPrompt(); return true; } return false; }; panelPaddingTop() { const headerHeight = Platform.OS === 'ios' ? 62.33 : 58.54; const promptButtonsSize = Platform.OS === 'ios' ? 40 : 61; const logInContainerSize = 165; const registerPanelSize = 246; // On large enough devices, we want to properly center the panels on screen. // But on smaller devices, this can lead to some issues: // - We need enough space below the log-in panel to render the // "Forgot password?" link // - On Android, ratchetAlongWithKeyboardHeight won't adjust the panel's // position when the keyboard size changes // To address these issues, we artifically increase the panel sizes so that // they get positioned a little higher than center on small devices. const smallDeviceThreshold = 600; const smallDeviceLogInContainerSize = 195; const smallDeviceRegisterPanelSize = 261; const containerSize = add( headerHeight, cond(not(isPastPrompt(this.modeValue)), promptButtonsSize, 0), cond( eq(this.modeValue, modeNumbers['log-in']), cond( lessThan(this.contentHeight, smallDeviceThreshold), smallDeviceLogInContainerSize, logInContainerSize, ), 0, ), cond( eq(this.modeValue, modeNumbers['register']), cond( lessThan(this.contentHeight, smallDeviceThreshold), smallDeviceRegisterPanelSize, registerPanelSize, ), 0, ), ); const potentialPanelPaddingTop = divide( max(sub(this.contentHeight, this.keyboardHeightValue, containerSize), 0), 2, ); const panelPaddingTop = new Value(-1); const targetPanelPaddingTop = new Value(-1); const prevModeValue = new Value(modeNumbers[this.nextMode]); const clock = new Clock(); const keyboardTimeoutClock = new Clock(); return block([ cond(lessThan(panelPaddingTop, 0), [ set(panelPaddingTop, potentialPanelPaddingTop), set(targetPanelPaddingTop, potentialPanelPaddingTop), ]), cond( lessThan(this.keyboardHeightValue, 0), [ runTiming(keyboardTimeoutClock, 0, 1, true, { duration: 500 }), cond( not(clockRunning(keyboardTimeoutClock)), set(this.keyboardHeightValue, 0), ), ], stopClock(keyboardTimeoutClock), ), cond( and( greaterOrEq(this.keyboardHeightValue, 0), neq(prevModeValue, this.modeValue), ), [ stopClock(clock), cond( neq(isPastPrompt(prevModeValue), isPastPrompt(this.modeValue)), set(targetPanelPaddingTop, potentialPanelPaddingTop), ), set(prevModeValue, this.modeValue), ], ), ratchetAlongWithKeyboardHeight(this.keyboardHeightValue, [ stopClock(clock), set(targetPanelPaddingTop, potentialPanelPaddingTop), ]), cond( neq(panelPaddingTop, targetPanelPaddingTop), set( panelPaddingTop, runTiming(clock, panelPaddingTop, targetPanelPaddingTop), ), ), panelPaddingTop, ]); } footerPaddingTop() { const textHeight = Platform.OS === 'ios' ? 17 : 19; const spacingAboveKeyboard = 15; const potentialFooterPaddingTop = max( sub( this.contentHeight, max(this.keyboardHeightValue, 0), textHeight, spacingAboveKeyboard, ), 0, ); const footerPaddingTop = new Value(-1); const targetFooterPaddingTop = new Value(-1); const clock = new Clock(); return block([ cond(lessThan(footerPaddingTop, 0), [ set(footerPaddingTop, potentialFooterPaddingTop), set(targetFooterPaddingTop, potentialFooterPaddingTop), ]), ratchetAlongWithKeyboardHeight(this.keyboardHeightValue, [ stopClock(clock), set(targetFooterPaddingTop, potentialFooterPaddingTop), ]), cond( neq(footerPaddingTop, targetFooterPaddingTop), set( footerPaddingTop, runTiming(clock, footerPaddingTop, targetFooterPaddingTop), ), ), footerPaddingTop, ]); } panelOpacity() { const targetPanelOpacity = isPastPrompt(this.modeValue); const panelOpacity = new Value(-1); const prevPanelOpacity = new Value(-1); const prevTargetPanelOpacity = new Value(-1); const clock = new Clock(); return block([ cond(lessThan(panelOpacity, 0), [ set(panelOpacity, targetPanelOpacity), set(prevPanelOpacity, targetPanelOpacity), set(prevTargetPanelOpacity, targetPanelOpacity), ]), cond(greaterOrEq(this.keyboardHeightValue, 0), [ cond(neq(targetPanelOpacity, prevTargetPanelOpacity), [ stopClock(clock), set(prevTargetPanelOpacity, targetPanelOpacity), ]), cond( neq(panelOpacity, targetPanelOpacity), set(panelOpacity, runTiming(clock, panelOpacity, targetPanelOpacity)), ), ]), cond( and(eq(panelOpacity, 0), neq(prevPanelOpacity, 0)), call([], this.proceedToNextMode), ), set(prevPanelOpacity, panelOpacity), panelOpacity, ]); } forgotPasswordLinkOpacity() { const targetForgotPasswordLinkOpacity = and( eq(this.modeValue, modeNumbers['log-in']), not(this.hideForgotPasswordLink), ); const forgotPasswordLinkOpacity = new Value(0); const prevTargetForgotPasswordLinkOpacity = new Value(0); const clock = new Clock(); return block([ cond(greaterOrEq(this.keyboardHeightValue, 0), [ cond( neq( targetForgotPasswordLinkOpacity, prevTargetForgotPasswordLinkOpacity, ), [ stopClock(clock), set( prevTargetForgotPasswordLinkOpacity, targetForgotPasswordLinkOpacity, ), ], ), cond( neq(forgotPasswordLinkOpacity, targetForgotPasswordLinkOpacity), set( forgotPasswordLinkOpacity, runTiming( clock, forgotPasswordLinkOpacity, targetForgotPasswordLinkOpacity, ), ), ), ]), forgotPasswordLinkOpacity, ]); } keyboardShow = (event: KeyboardEvent) => { if (_isEqual(event.startCoordinates)(event.endCoordinates)) { return; } const keyboardHeight = Platform.select({ // Android doesn't include the bottomInset in this height measurement android: event.endCoordinates.height, default: Math.max( event.endCoordinates.height - this.props.dimensions.bottomInset, 0, ), }); this.keyboardHeightValue.setValue(keyboardHeight); }; keyboardHide = () => { if (!this.activeAlert) { this.keyboardHeightValue.setValue(0); } }; setActiveAlert = (activeAlert: boolean) => { this.activeAlert = activeAlert; }; goBackToPrompt = () => { this.nextMode = 'prompt'; this.keyboardHeightValue.setValue(0); this.modeValue.setValue(modeNumbers['prompt']); Keyboard.dismiss(); }; render() { let panel = null; let buttons = null; if (this.state.mode === 'log-in') { panel = ( ); } else if (this.state.mode === 'register') { panel = ( ); } else if (this.state.mode === 'prompt') { const opacityStyle = { opacity: this.buttonOpacity }; buttons = ( LOG IN SIGN UP ); } else if (this.state.mode === 'loading') { panel = ( ); } let forgotPasswordLink = null; if (this.state.mode === 'log-in') { const reanimatedStyle = { top: this.footerPaddingTopValue, opacity: this.forgotPasswordLinkOpacityValue, }; forgotPasswordLink = ( Forgot password? ); } const windowWidth = this.props.dimensions.width; const buttonStyle = { opacity: this.panelOpacityValue, left: windowWidth < 360 ? 28 : 40, }; const padding = { paddingTop: this.panelPaddingTopValue }; const animatedContent = ( SquadCal {panel} ); const backgroundSource = { uri: splashBackgroundURI }; return ( {animatedContent} {buttons} {forgotPasswordLink} ); } logInPanelContainerRef = (logInPanelContainer: ?LogInPanelContainer) => { this.logInPanelContainer = logInPanelContainer; }; onPressLogIn = () => { if (Platform.OS !== 'ios') { // For some strange reason, iOS's password management logic doesn't // realize that the username and password fields in LogInPanel are related // if the username field gets focused on mount. To avoid this issue we // need the username and password fields to both appear on-screen before // we focus the username field. However, when we set keyboardHeightValue // to -1 here, we are telling our Reanimated logic to wait until the // keyboard appears before showing LogInPanel. Since we need LogInPanel // to appear before the username field is focused, we need to avoid this // behavior on iOS. this.keyboardHeightValue.setValue(-1); } this.setMode('log-in'); }; onPressRegister = () => { this.keyboardHeightValue.setValue(-1); this.setMode('register'); }; onPressForgotPassword = () => { invariant(this.logInPanelContainer, 'ref should be set'); this.logInPanelContainer.onPressForgotPassword(); }; } const styles = StyleSheet.create({ animationContainer: { flex: 1, }, backButton: { position: 'absolute', top: 13, }, button: { backgroundColor: '#FFFFFFAA', borderRadius: 6, marginBottom: 10, marginLeft: 40, marginRight: 40, marginTop: 10, paddingBottom: 6, paddingLeft: 18, paddingRight: 18, paddingTop: 6, }, buttonContainer: { bottom: 0, left: 0, paddingBottom: 20, position: 'absolute', right: 0, }, buttonText: { color: '#000000FF', fontFamily: 'OpenSans-Semibold', fontSize: 22, textAlign: 'center', }, container: { backgroundColor: 'transparent', flex: 1, }, forgotPasswordText: { color: '#8899FF', }, forgotPasswordTextContainer: { alignSelf: 'flex-end', position: 'absolute', right: 20, }, header: { color: 'white', fontFamily: 'Anaheim-Regular', fontSize: 48, textAlign: 'center', }, loadingIndicator: { paddingTop: 15, }, modalBackground: { bottom: 0, left: 0, position: 'absolute', right: 0, top: 0, }, }); const isForegroundSelector = createIsForegroundSelector( LoggedOutModalRouteName, ); export default React.memo<{ ... }>(function ConnectedLoggedOutModal(props: { ... }) { const navContext = React.useContext(NavContext); const isForeground = isForegroundSelector(navContext); const rehydrateConcluded = useSelector( (state) => !!(state._persist && state._persist.rehydrated && navContext), ); const cookie = useSelector((state) => state.cookie); const urlPrefix = useSelector((state) => state.urlPrefix); const loggedIn = useSelector(isLoggedIn); const dimensions = useSelector(derivedDimensionsInfoSelector); const splashStyle = useSelector(splashStyleSelector); const dispatch = useDispatch(); return ( ); }); diff --git a/native/account/modal-components.react.js b/native/account/modal-components.react.js index 81b9f0061..da0e83846 100644 --- a/native/account/modal-components.react.js +++ b/native/account/modal-components.react.js @@ -1,60 +1,60 @@ // @flow -import type { AppState } from '../redux/redux-setup'; - +import invariant from 'invariant'; import * as React from 'react'; import { TextInput as BaseTextInput, View, StyleSheet } from 'react-native'; -import invariant from 'invariant'; import { createSelector } from 'reselect'; +import type { AppState } from '../redux/redux-setup'; + class TextInput extends React.PureComponent<*> { innerTextInput: ?React.ElementRef; render() { const style = [styles.textInput, this.props.style]; return ( ); } innerTextInputRef = ( innerTextInput: ?React.ElementRef, ) => { this.innerTextInput = innerTextInput; }; focus() { invariant(this.innerTextInput, 'ref should exist'); this.innerTextInput.focus(); } } const styles = StyleSheet.create({ textInput: { + borderBottomColor: 'transparent', color: 'black', fontSize: 20, height: 40, margin: 0, padding: 0, - borderBottomColor: 'transparent', }, textInputWrapperView: { borderBottomColor: '#BBBBBB', borderBottomWidth: 1, }, }); const usernamePlaceholderSelector: (state: AppState) => string = createSelector( (state: AppState) => state.dimensions.width, (windowWidth: number): string => windowWidth < 360 ? 'Username or email' : 'Username or email address', ); export { TextInput, usernamePlaceholderSelector }; diff --git a/native/account/panel-components.react.js b/native/account/panel-components.react.js index 892154d9d..251451609 100644 --- a/native/account/panel-components.react.js +++ b/native/account/panel-components.react.js @@ -1,219 +1,217 @@ // @flow import type { LoadingStatus } from 'lib/types/loading-types'; import { loadingStatusPropType } from 'lib/types/loading-types'; -import type { ViewStyle } from '../types/styles'; -import type { KeyboardEvent, EmitterSubscription } from '../keyboard/keyboard'; -import { - type DimensionsInfo, - dimensionsInfoPropType, -} from '../redux/dimensions-updater.react'; -import type { AppState } from '../redux/redux-setup'; - +import { connect } from 'lib/utils/redux-utils'; +import PropTypes from 'prop-types'; import * as React from 'react'; import { View, ActivityIndicator, Text, StyleSheet, ScrollView, LayoutAnimation, ViewPropTypes, } from 'react-native'; -import Icon from 'react-native-vector-icons/FontAwesome'; -import PropTypes from 'prop-types'; import Animated from 'react-native-reanimated'; - -import { connect } from 'lib/utils/redux-utils'; +import Icon from 'react-native-vector-icons/FontAwesome'; import Button from '../components/button.react'; +import type { KeyboardEvent, EmitterSubscription } from '../keyboard/keyboard'; import { addKeyboardShowListener, addKeyboardDismissListener, removeKeyboardListener, } from '../keyboard/keyboard'; +import { + type DimensionsInfo, + dimensionsInfoPropType, +} from '../redux/dimensions-updater.react'; +import type { AppState } from '../redux/redux-setup'; +import type { ViewStyle } from '../types/styles'; type ButtonProps = {| text: string, loadingStatus: LoadingStatus, onSubmit: () => void, |}; class PanelButton extends React.PureComponent { static propTypes = { text: PropTypes.string.isRequired, loadingStatus: loadingStatusPropType.isRequired, onSubmit: PropTypes.func.isRequired, }; render() { let buttonIcon; if (this.props.loadingStatus === 'loading') { buttonIcon = ( ); } else { buttonIcon = ( ); } return ( ); } } const scrollViewBelow = 568; type PanelProps = {| opacityValue: Animated.Value, children: React.Node, style?: ViewStyle, dimensions: DimensionsInfo, |}; type PanelState = {| keyboardHeight: number, |}; class InnerPanel extends React.PureComponent { static propTypes = { opacityValue: PropTypes.object.isRequired, children: PropTypes.node.isRequired, style: ViewPropTypes.style, dimensions: dimensionsInfoPropType.isRequired, }; state: PanelState = { keyboardHeight: 0, }; keyboardShowListener: ?EmitterSubscription; keyboardHideListener: ?EmitterSubscription; componentDidMount() { this.keyboardShowListener = addKeyboardShowListener(this.keyboardHandler); this.keyboardHideListener = addKeyboardDismissListener( this.keyboardHandler, ); } componentWillUnmount() { if (this.keyboardShowListener) { removeKeyboardListener(this.keyboardShowListener); this.keyboardShowListener = null; } if (this.keyboardHideListener) { removeKeyboardListener(this.keyboardHideListener); this.keyboardHideListener = null; } } keyboardHandler = (event: ?KeyboardEvent) => { const frameEdge = this.props.dimensions.height - this.props.dimensions.bottomInset; const keyboardHeight = event ? frameEdge - event.endCoordinates.screenY : 0; if (keyboardHeight === this.state.keyboardHeight) { return; } const windowHeight = this.props.dimensions.height; if ( windowHeight < scrollViewBelow && event && event.duration && event.easing ) { LayoutAnimation.configureNext({ duration: event.duration, update: { duration: event.duration, type: LayoutAnimation.Types[event.easing] || 'keyboard', }, }); } this.setState({ keyboardHeight }); }; render() { const windowHeight = this.props.dimensions.height; const containerStyle = { opacity: this.props.opacityValue, marginTop: windowHeight < 600 ? 15 : 40, }; const content = ( {this.props.children} ); if (windowHeight >= scrollViewBelow) { return content; } const scrollViewStyle = { paddingBottom: 73.5 + this.state.keyboardHeight, }; return ( {content} ); } } const Panel = connect((state: AppState) => ({ dimensions: state.dimensions, }))(InnerPanel); const styles = StyleSheet.create({ container: { backgroundColor: '#FFFFFFAA', borderRadius: 6, marginLeft: 20, marginRight: 20, paddingBottom: 37, paddingLeft: 18, paddingRight: 18, paddingTop: 6, }, loadingIndicatorContainer: { paddingBottom: 2, width: 14, }, submitButton: { borderBottomRightRadius: 6, bottom: 0, position: 'absolute', right: 0, }, submitContentContainer: { alignItems: 'flex-end', flexDirection: 'row', paddingHorizontal: 18, paddingVertical: 6, }, submitContentIconContainer: { paddingBottom: 5, width: 14, }, submitContentText: { color: '#555', fontFamily: 'OpenSans-Semibold', fontSize: 18, paddingRight: 7, }, }); export { PanelButton, Panel }; diff --git a/native/account/register-panel.react.js b/native/account/register-panel.react.js index ac4826273..11e5382e9 100644 --- a/native/account/register-panel.react.js +++ b/native/account/register-panel.react.js @@ -1,468 +1,467 @@ // @flow -import type { LoadingStatus } from 'lib/types/loading-types'; +import invariant from 'invariant'; +import { registerActionTypes, register } from 'lib/actions/user-actions'; +import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; +import { validUsernameRegex, validEmailRegex } from 'lib/shared/account-utils'; import type { RegisterInfo, LogInExtraInfo, RegisterResult, LogInStartingPayload, } from 'lib/types/account-types'; -import { type StateContainer } from '../utils/state-container'; - -import React from 'react'; -import { View, StyleSheet, Platform, Keyboard, Alert } from 'react-native'; -import Icon from 'react-native-vector-icons/FontAwesome'; -import invariant from 'invariant'; -import Animated from 'react-native-reanimated'; - -import { registerActionTypes, register } from 'lib/actions/user-actions'; -import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; -import { validUsernameRegex, validEmailRegex } from 'lib/shared/account-utils'; +import type { LoadingStatus } from 'lib/types/loading-types'; import { useServerCall, useDispatchActionPromise, type DispatchActionPromise, } from 'lib/utils/action-utils'; +import React from 'react'; +import { View, StyleSheet, Platform, Keyboard, Alert } from 'react-native'; +import Animated from 'react-native-reanimated'; +import Icon from 'react-native-vector-icons/FontAwesome'; -import { TextInput } from './modal-components.react'; -import { PanelButton, Panel } from './panel-components.react'; -import { setNativeCredentials } from './native-credentials'; -import { nativeLogInExtraInfoSelector } from '../selectors/account-selectors'; import { NavContext } from '../navigation/navigation-context'; import { useSelector } from '../redux/redux-utils'; +import { nativeLogInExtraInfoSelector } from '../selectors/account-selectors'; +import { type StateContainer } from '../utils/state-container'; + +import { TextInput } from './modal-components.react'; +import { setNativeCredentials } from './native-credentials'; +import { PanelButton, Panel } from './panel-components.react'; export type RegisterState = {| +usernameInputText: string, +emailInputText: string, +passwordInputText: string, +confirmPasswordInputText: string, |}; type BaseProps = {| +setActiveAlert: (activeAlert: boolean) => void, +opacityValue: Animated.Value, +state: StateContainer, |}; type Props = {| ...BaseProps, // Redux state +loadingStatus: LoadingStatus, +logInExtraInfo: () => LogInExtraInfo, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +register: (registerInfo: RegisterInfo) => Promise, |}; type State = {| +confirmPasswordFocused: boolean, |}; class RegisterPanel extends React.PureComponent { state: State = { confirmPasswordFocused: false, }; usernameInput: ?TextInput; emailInput: ?TextInput; passwordInput: ?TextInput; confirmPasswordInput: ?TextInput; passwordBeingAutoFilled = false; render() { let confirmPasswordTextInputExtraProps; if ( Platform.OS !== 'ios' || this.state.confirmPasswordFocused || this.props.state.state.confirmPasswordInputText.length > 0 ) { confirmPasswordTextInputExtraProps = { secureTextEntry: true, textContentType: 'password', }; } let onPasswordKeyPress; if (Platform.OS === 'ios') { onPasswordKeyPress = this.onPasswordKeyPress; } return ( ); } usernameInputRef = (usernameInput: ?TextInput) => { this.usernameInput = usernameInput; }; emailInputRef = (emailInput: ?TextInput) => { this.emailInput = emailInput; }; passwordInputRef = (passwordInput: ?TextInput) => { this.passwordInput = passwordInput; }; confirmPasswordInputRef = (confirmPasswordInput: ?TextInput) => { this.confirmPasswordInput = confirmPasswordInput; }; focusUsernameInput = () => { invariant(this.usernameInput, 'ref should be set'); this.usernameInput.focus(); }; focusPasswordInput = () => { invariant(this.passwordInput, 'ref should be set'); this.passwordInput.focus(); }; focusConfirmPasswordInput = () => { invariant(this.confirmPasswordInput, 'ref should be set'); this.confirmPasswordInput.focus(); }; onChangeUsernameInputText = (text: string) => { this.props.state.setState({ usernameInputText: text }); }; onChangeEmailInputText = (text: string) => { this.props.state.setState({ emailInputText: text }); if (this.props.state.state.emailInputText.length === 0 && text.length > 1) { this.focusUsernameInput(); } }; onEmailBlur = () => { const trimmedEmail = this.props.state.state.emailInputText.trim(); if (trimmedEmail !== this.props.state.state.emailInputText) { this.props.state.setState({ emailInputText: trimmedEmail }); } }; onChangePasswordInputText = (text: string) => { const stateUpdate = {}; stateUpdate.passwordInputText = text; if (this.passwordBeingAutoFilled) { this.passwordBeingAutoFilled = false; stateUpdate.confirmPasswordInputText = text; } this.props.state.setState(stateUpdate); }; onPasswordKeyPress = ( event: $ReadOnly<{ nativeEvent: $ReadOnly<{ key: string }> }>, ) => { const { key } = event.nativeEvent; if ( key.length > 1 && key !== 'Backspace' && key !== 'Enter' && this.props.state.state.confirmPasswordInputText.length === 0 ) { this.passwordBeingAutoFilled = true; } }; onChangeConfirmPasswordInputText = (text: string) => { this.props.state.setState({ confirmPasswordInputText: text }); }; onConfirmPasswordFocus = () => { this.setState({ confirmPasswordFocused: true }); }; onSubmit = () => { this.props.setActiveAlert(true); if (this.props.state.state.passwordInputText === '') { Alert.alert( 'Empty password', 'Password cannot be empty', [{ text: 'OK', onPress: this.onPasswordAlertAcknowledged }], { cancelable: false }, ); } else if ( this.props.state.state.passwordInputText !== this.props.state.state.confirmPasswordInputText ) { Alert.alert( "Passwords don't match", 'Password fields must contain the same password', [{ text: 'OK', onPress: this.onPasswordAlertAcknowledged }], { cancelable: false }, ); } else if ( this.props.state.state.usernameInputText.search(validUsernameRegex) === -1 ) { Alert.alert( 'Invalid username', 'Usernames must be at least six characters long, start with either a ' + 'letter or a number, and may contain only letters, numbers, or the ' + 'characters “-” and “_”', [{ text: 'OK', onPress: this.onUsernameAlertAcknowledged }], { cancelable: false }, ); } else if ( this.props.state.state.emailInputText.search(validEmailRegex) === -1 ) { Alert.alert( 'Invalid email address', 'Valid email addresses only', [{ text: 'OK', onPress: this.onEmailAlertAcknowledged }], { cancelable: false }, ); } else { Keyboard.dismiss(); const extraInfo = this.props.logInExtraInfo(); this.props.dispatchActionPromise( registerActionTypes, this.registerAction(extraInfo), undefined, ({ calendarQuery: extraInfo.calendarQuery }: LogInStartingPayload), ); } }; onPasswordAlertAcknowledged = () => { this.props.setActiveAlert(false); this.props.state.setState( { passwordInputText: '', confirmPasswordInputText: '', }, () => { invariant(this.passwordInput, 'ref should exist'); this.passwordInput.focus(); }, ); }; onUsernameAlertAcknowledged = () => { this.props.setActiveAlert(false); this.props.state.setState( { usernameInputText: '', }, () => { invariant(this.usernameInput, 'ref should exist'); this.usernameInput.focus(); }, ); }; onEmailAlertAcknowledged = () => { this.props.setActiveAlert(false); this.props.state.setState( { emailInputText: '', }, () => { invariant(this.emailInput, 'ref should exist'); this.emailInput.focus(); }, ); }; async registerAction(extraInfo: LogInExtraInfo) { try { const result = await this.props.register({ username: this.props.state.state.usernameInputText, email: this.props.state.state.emailInputText, password: this.props.state.state.passwordInputText, ...extraInfo, }); this.props.setActiveAlert(false); await setNativeCredentials({ username: result.currentUserInfo.username, password: this.props.state.state.passwordInputText, }); return result; } catch (e) { if (e.message === 'username_taken') { Alert.alert( 'Username taken', 'An account with that username already exists', [{ text: 'OK', onPress: this.onUsernameAlertAcknowledged }], { cancelable: false }, ); } else if (e.message === 'email_taken') { Alert.alert( 'Email taken', 'An account with that email already exists', [{ text: 'OK', onPress: this.onEmailAlertAcknowledged }], { cancelable: false }, ); } else if (e.message === 'client_version_unsupported') { const app = Platform.select({ ios: 'Testflight', android: 'Play Store', }); Alert.alert( 'App out of date', "Your app version is pretty old, and the server doesn't know how " + `to speak to it anymore. Please use the ${app} app to update!`, [{ text: 'OK', onPress: this.onAppOutOfDateAlertAcknowledged }], { cancelable: false }, ); } else { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: this.onUnknownErrorAlertAcknowledged }], { cancelable: false }, ); } throw e; } } onUnknownErrorAlertAcknowledged = () => { this.props.setActiveAlert(false); this.props.state.setState( { usernameInputText: '', emailInputText: '', passwordInputText: '', confirmPasswordInputText: '', }, () => { invariant(this.usernameInput, 'ref should exist'); this.usernameInput.focus(); }, ); }; onAppOutOfDateAlertAcknowledged = () => { this.props.setActiveAlert(false); }; } const styles = StyleSheet.create({ container: { paddingBottom: Platform.OS === 'ios' ? 37 : 36, zIndex: 2, }, envelopeIcon: { bottom: 10, left: 3, }, icon: { bottom: 8, left: 4, position: 'absolute', }, input: { paddingLeft: 35, }, }); const loadingStatusSelector = createLoadingStatusSelector(registerActionTypes); export default React.memo(function ConnectedRegisterPanel( props: BaseProps, ) { const loadingStatus = useSelector(loadingStatusSelector); const navContext = React.useContext(NavContext); const logInExtraInfo = useSelector((state) => nativeLogInExtraInfoSelector({ redux: state, navContext, }), ); const dispatchActionPromise = useDispatchActionPromise(); const callRegister = useServerCall(register); return ( ); }); diff --git a/native/account/reset-password-panel.react.js b/native/account/reset-password-panel.react.js index 411467647..3dc7f06a2 100644 --- a/native/account/reset-password-panel.react.js +++ b/native/account/reset-password-panel.react.js @@ -1,308 +1,307 @@ // @flow -import type { LoadingStatus } from 'lib/types/loading-types'; +import invariant from 'invariant'; +import { + resetPasswordActionTypes, + resetPassword, +} from 'lib/actions/user-actions'; +import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import type { UpdatePasswordInfo, LogInExtraInfo, LogInResult, LogInStartingPayload, } from 'lib/types/account-types'; - +import type { LoadingStatus } from 'lib/types/loading-types'; +import { + useServerCall, + useDispatchActionPromise, + type DispatchActionPromise, +} from 'lib/utils/action-utils'; import React from 'react'; import { Alert, StyleSheet, Keyboard, View, Text, Platform, } from 'react-native'; -import invariant from 'invariant'; -import Icon from 'react-native-vector-icons/FontAwesome'; import Animated from 'react-native-reanimated'; +import Icon from 'react-native-vector-icons/FontAwesome'; -import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; -import { - resetPasswordActionTypes, - resetPassword, -} from 'lib/actions/user-actions'; -import { - useServerCall, - useDispatchActionPromise, - type DispatchActionPromise, -} from 'lib/utils/action-utils'; +import { NavContext } from '../navigation/navigation-context'; +import { useSelector } from '../redux/redux-utils'; +import { nativeLogInExtraInfoSelector } from '../selectors/account-selectors'; import { TextInput } from './modal-components.react'; import { PanelButton, Panel } from './panel-components.react'; -import { nativeLogInExtraInfoSelector } from '../selectors/account-selectors'; -import { NavContext } from '../navigation/navigation-context'; -import { useSelector } from '../redux/redux-utils'; type BaseProps = {| +verifyCode: string, +username: string, +onSuccess: () => Promise, +setActiveAlert: (activeAlert: boolean) => void, +opacityValue: Animated.Value, |}; type Props = {| ...BaseProps, // Redux state +loadingStatus: LoadingStatus, +logInExtraInfo: () => LogInExtraInfo, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +resetPassword: (info: UpdatePasswordInfo) => Promise, |}; type State = {| +passwordInputText: string, +confirmPasswordInputText: string, |}; class ResetPasswordPanel extends React.PureComponent { state: State = { passwordInputText: '', confirmPasswordInputText: '', }; passwordInput: ?TextInput; confirmPasswordInput: ?TextInput; passwordBeingAutoFilled = false; render() { let onPasswordKeyPress; if (Platform.OS === 'ios') { onPasswordKeyPress = this.onPasswordKeyPress; } return ( {this.props.username} ); } passwordInputRef = (passwordInput: ?TextInput) => { this.passwordInput = passwordInput; }; confirmPasswordInputRef = (confirmPasswordInput: ?TextInput) => { this.confirmPasswordInput = confirmPasswordInput; }; focusConfirmPasswordInput = () => { invariant(this.confirmPasswordInput, 'ref should be set'); this.confirmPasswordInput.focus(); }; onChangePasswordInputText = (text: string) => { const stateUpdate = {}; stateUpdate.passwordInputText = text; if (this.passwordBeingAutoFilled) { this.passwordBeingAutoFilled = false; stateUpdate.confirmPasswordInputText = text; } this.setState(stateUpdate); }; onPasswordKeyPress = ( event: $ReadOnly<{ nativeEvent: $ReadOnly<{ key: string }> }>, ) => { const { key } = event.nativeEvent; if ( key.length > 1 && key !== 'Backspace' && key !== 'Enter' && this.state.confirmPasswordInputText.length === 0 ) { this.passwordBeingAutoFilled = true; } }; onChangeConfirmPasswordInputText = (text: string) => { this.setState({ confirmPasswordInputText: text }); }; onSubmit = () => { this.props.setActiveAlert(true); if (this.state.passwordInputText === '') { Alert.alert( 'Empty password', 'Password cannot be empty', [{ text: 'OK', onPress: this.onPasswordAlertAcknowledged }], { cancelable: false }, ); return; } else if ( this.state.passwordInputText !== this.state.confirmPasswordInputText ) { Alert.alert( "Passwords don't match", 'Password fields must contain the same password', [{ text: 'OK', onPress: this.onPasswordAlertAcknowledged }], { cancelable: false }, ); return; } Keyboard.dismiss(); const extraInfo = this.props.logInExtraInfo(); this.props.dispatchActionPromise( resetPasswordActionTypes, this.resetPasswordAction(extraInfo), undefined, ({ calendarQuery: extraInfo.calendarQuery }: LogInStartingPayload), ); }; onPasswordAlertAcknowledged = () => { this.props.setActiveAlert(false); this.setState( { passwordInputText: '', confirmPasswordInputText: '', }, () => { invariant(this.passwordInput, 'ref should exist'); this.passwordInput.focus(); }, ); }; async resetPasswordAction(extraInfo: LogInExtraInfo) { try { const result = await this.props.resetPassword({ ...extraInfo, code: this.props.verifyCode, password: this.state.passwordInputText, }); this.props.setActiveAlert(false); await this.props.onSuccess(); return result; } catch (e) { if (e.message === 'client_version_unsupported') { const app = Platform.select({ ios: 'Testflight', android: 'Play Store', }); Alert.alert( 'App out of date', "Your app version is pretty old, and the server doesn't know how " + `to speak to it anymore. Please use the ${app} app to update!`, [{ text: 'OK', onPress: this.onAppOutOfDateAlertAcknowledged }], { cancelable: false }, ); } else { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: this.onPasswordAlertAcknowledged }], { cancelable: false }, ); throw e; } } } onAppOutOfDateAlertAcknowledged = () => { this.props.setActiveAlert(false); }; } const styles = StyleSheet.create({ container: { marginTop: 0, }, icon: { bottom: 8, left: 4, position: 'absolute', }, input: { paddingLeft: 35, }, usernameContainer: { borderBottomColor: '#BBBBBB', borderBottomWidth: 1, paddingLeft: 35, }, usernameText: { color: '#444', fontSize: 20, height: 40, paddingTop: 8, }, }); const loadingStatusSelector = createLoadingStatusSelector( resetPasswordActionTypes, ); export default React.memo(function ConnectedResetPasswordPanel( props: BaseProps, ) { const loadingStatus = useSelector(loadingStatusSelector); const navContext = React.useContext(NavContext); const logInExtraInfo = useSelector((state) => nativeLogInExtraInfoSelector({ redux: state, navContext, }), ); const dispatchActionPromise = useDispatchActionPromise(); const callResetPassword = useServerCall(resetPassword); return ( ); }); diff --git a/native/account/resolve-invalidated-cookie.js b/native/account/resolve-invalidated-cookie.js index bd1039339..ba9ed97d4 100644 --- a/native/account/resolve-invalidated-cookie.js +++ b/native/account/resolve-invalidated-cookie.js @@ -1,63 +1,63 @@ // @flow -import type { FetchJSON } from 'lib/utils/fetch-json'; -import type { DispatchRecoveryAttempt } from 'lib/utils/action-utils'; +import { logInActionTypes, logIn } from 'lib/actions/user-actions'; import type { LogInActionSource } from 'lib/types/account-types'; +import type { DispatchRecoveryAttempt } from 'lib/utils/action-utils'; +import type { FetchJSON } from 'lib/utils/fetch-json'; -import { logInActionTypes, logIn } from 'lib/actions/user-actions'; +import { getGlobalNavContext } from '../navigation/icky-global'; +import { store } from '../redux/redux-setup'; +import { nativeLogInExtraInfoSelector } from '../selectors/account-selectors'; import { fetchNativeKeychainCredentials, getNativeSharedWebCredentials, } from './native-credentials'; -import { nativeLogInExtraInfoSelector } from '../selectors/account-selectors'; -import { store } from '../redux/redux-setup'; -import { getGlobalNavContext } from '../navigation/icky-global'; async function resolveInvalidatedCookie( fetchJSON: FetchJSON, dispatchRecoveryAttempt: DispatchRecoveryAttempt, source?: LogInActionSource, ) { const keychainCredentials = await fetchNativeKeychainCredentials(); if (keychainCredentials) { const extraInfo = nativeLogInExtraInfoSelector({ redux: store.getState(), navContext: getGlobalNavContext(), })(); const { calendarQuery } = extraInfo; const newCookie = await dispatchRecoveryAttempt( logInActionTypes, logIn(fetchJSON, { usernameOrEmail: keychainCredentials.username, password: keychainCredentials.password, source, ...extraInfo, }), { calendarQuery }, ); if (newCookie) { return; } } const sharedWebCredentials = getNativeSharedWebCredentials(); if (sharedWebCredentials) { const extraInfo = nativeLogInExtraInfoSelector({ redux: store.getState(), navContext: getGlobalNavContext(), })(); const { calendarQuery } = extraInfo; await dispatchRecoveryAttempt( logInActionTypes, logIn(fetchJSON, { usernameOrEmail: sharedWebCredentials.username, password: sharedWebCredentials.password, source, ...extraInfo, }), { calendarQuery }, ); } } export { resolveInvalidatedCookie }; diff --git a/native/account/verification-modal.react.js b/native/account/verification-modal.react.js index 9d18ab927..f71399e8f 100644 --- a/native/account/verification-modal.react.js +++ b/native/account/verification-modal.react.js @@ -1,574 +1,573 @@ // @flow +import invariant from 'invariant'; +import { + handleVerificationCodeActionTypes, + handleVerificationCode, +} from 'lib/actions/user-actions'; +import { registerFetchKey } from 'lib/reducers/loading-reducer'; import { type VerifyField, verifyField, type HandleVerificationCodeResult, } from 'lib/types/verify-types'; -import type { KeyboardEvent } from '../keyboard/keyboard'; -import type { ImageStyle } from '../types/styles'; -import type { RootNavigationProp } from '../navigation/root-navigator.react'; -import type { NavigationRoute } from '../navigation/route-names'; - +import { + useServerCall, + useDispatchActionPromise, + type DispatchActionPromise, +} from 'lib/utils/action-utils'; +import sleep from 'lib/utils/sleep'; import * as React from 'react'; import { Image, Text, View, StyleSheet, ActivityIndicator, Platform, Keyboard, TouchableHighlight, } from 'react-native'; -import Icon from 'react-native-vector-icons/FontAwesome'; -import invariant from 'invariant'; -import { SafeAreaView } from 'react-native-safe-area-context'; import Animated from 'react-native-reanimated'; - -import { registerFetchKey } from 'lib/reducers/loading-reducer'; -import { - handleVerificationCodeActionTypes, - handleVerificationCode, -} from 'lib/actions/user-actions'; -import sleep from 'lib/utils/sleep'; -import { - useServerCall, - useDispatchActionPromise, - type DispatchActionPromise, -} from 'lib/utils/action-utils'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import Icon from 'react-native-vector-icons/FontAwesome'; import ConnectedStatusBar from '../connected-status-bar.react'; -import ResetPasswordPanel from './reset-password-panel.react'; -import { createIsForegroundSelector } from '../navigation/nav-selectors'; -import { splashBackgroundURI } from './background-info'; -import { splashStyleSelector } from '../splash'; +import type { KeyboardEvent } from '../keyboard/keyboard'; import { addKeyboardShowListener, addKeyboardDismissListener, removeKeyboardListener, } from '../keyboard/keyboard'; -import { VerificationModalRouteName } from '../navigation/route-names'; +import { createIsForegroundSelector } from '../navigation/nav-selectors'; import { NavContext } from '../navigation/navigation-context'; -import { - runTiming, - ratchetAlongWithKeyboardHeight, -} from '../utils/animation-utils'; +import type { RootNavigationProp } from '../navigation/root-navigator.react'; +import { VerificationModalRouteName } from '../navigation/route-names'; +import type { NavigationRoute } from '../navigation/route-names'; +import { useSelector } from '../redux/redux-utils'; import { type DerivedDimensionsInfo, derivedDimensionsInfoSelector, } from '../selectors/dimensions-selectors'; -import { useSelector } from '../redux/redux-utils'; +import { splashStyleSelector } from '../splash'; +import type { ImageStyle } from '../types/styles'; +import { + runTiming, + ratchetAlongWithKeyboardHeight, +} from '../utils/animation-utils'; + +import { splashBackgroundURI } from './background-info'; +import ResetPasswordPanel from './reset-password-panel.react'; const safeAreaEdges = ['top', 'bottom']; /* eslint-disable import/no-named-as-default-member */ const { Value, Clock, block, set, call, cond, not, and, eq, neq, lessThan, greaterOrEq, sub, divide, max, stopClock, clockRunning, } = Animated; /* eslint-enable import/no-named-as-default-member */ export type VerificationModalParams = {| +verifyCode: string, |}; type VerificationModalMode = 'simple-text' | 'reset-password'; const modeNumbers: { [VerificationModalMode]: number } = { 'simple-text': 0, 'reset-password': 1, }; type BaseProps = {| +navigation: RootNavigationProp<'VerificationModal'>, +route: NavigationRoute<'VerificationModal'>, |}; type Props = {| ...BaseProps, // Navigation state +isForeground: boolean, // Redux state +dimensions: DerivedDimensionsInfo, +splashStyle: ImageStyle, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +handleVerificationCode: ( code: string, ) => Promise, |}; type State = {| +mode: VerificationModalMode, +verifyField: ?VerifyField, +errorMessage: ?string, +resetPasswordUsername: ?string, |}; class VerificationModal extends React.PureComponent { keyboardShowListener: ?Object; keyboardHideListener: ?Object; activeAlert = false; nextMode: VerificationModalMode = 'simple-text'; contentHeight: Value; keyboardHeightValue = new Value(0); modeValue: Value; paddingTopValue: Value; resetPasswordPanelOpacityValue: Value; constructor(props: Props) { super(props); this.state = { mode: 'simple-text', verifyField: null, errorMessage: null, resetPasswordUsername: null, }; this.contentHeight = new Value(props.dimensions.safeAreaHeight); this.modeValue = new Value(modeNumbers[this.nextMode]); this.paddingTopValue = this.paddingTop(); this.resetPasswordPanelOpacityValue = this.resetPasswordPanelOpacity(); } proceedToNextMode = () => { this.setState({ mode: this.nextMode }); }; paddingTop() { const simpleTextHeight = 90; const resetPasswordPanelHeight = 165; const potentialPaddingTop = divide( max( sub( this.contentHeight, cond( eq(this.modeValue, modeNumbers['simple-text']), simpleTextHeight, ), cond( eq(this.modeValue, modeNumbers['reset-password']), resetPasswordPanelHeight, ), this.keyboardHeightValue, ), 0, ), 2, ); const paddingTop = new Value(-1); const targetPaddingTop = new Value(-1); const prevModeValue = new Value(modeNumbers[this.nextMode]); const clock = new Clock(); const keyboardTimeoutClock = new Clock(); return block([ cond(lessThan(paddingTop, 0), [ set(paddingTop, potentialPaddingTop), set(targetPaddingTop, potentialPaddingTop), ]), cond( lessThan(this.keyboardHeightValue, 0), [ runTiming(keyboardTimeoutClock, 0, 1, true, { duration: 500 }), cond( not(clockRunning(keyboardTimeoutClock)), set(this.keyboardHeightValue, 0), ), ], stopClock(keyboardTimeoutClock), ), cond( and( greaterOrEq(this.keyboardHeightValue, 0), neq(prevModeValue, this.modeValue), ), [ stopClock(clock), set(targetPaddingTop, potentialPaddingTop), set(prevModeValue, this.modeValue), ], ), ratchetAlongWithKeyboardHeight(this.keyboardHeightValue, [ stopClock(clock), set(targetPaddingTop, potentialPaddingTop), ]), cond( neq(paddingTop, targetPaddingTop), set(paddingTop, runTiming(clock, paddingTop, targetPaddingTop)), ), paddingTop, ]); } resetPasswordPanelOpacity() { const targetResetPasswordPanelOpacity = eq( this.modeValue, modeNumbers['reset-password'], ); const resetPasswordPanelOpacity = new Value(-1); const prevResetPasswordPanelOpacity = new Value(-1); const prevTargetResetPasswordPanelOpacity = new Value(-1); const clock = new Clock(); return block([ cond(lessThan(resetPasswordPanelOpacity, 0), [ set(resetPasswordPanelOpacity, targetResetPasswordPanelOpacity), set(prevResetPasswordPanelOpacity, targetResetPasswordPanelOpacity), set( prevTargetResetPasswordPanelOpacity, targetResetPasswordPanelOpacity, ), ]), cond(greaterOrEq(this.keyboardHeightValue, 0), [ cond( neq( targetResetPasswordPanelOpacity, prevTargetResetPasswordPanelOpacity, ), [ stopClock(clock), set( prevTargetResetPasswordPanelOpacity, targetResetPasswordPanelOpacity, ), ], ), cond( neq(resetPasswordPanelOpacity, targetResetPasswordPanelOpacity), set( resetPasswordPanelOpacity, runTiming( clock, resetPasswordPanelOpacity, targetResetPasswordPanelOpacity, ), ), ), ]), cond( and( eq(resetPasswordPanelOpacity, 0), neq(prevResetPasswordPanelOpacity, 0), ), call([], this.proceedToNextMode), ), set(prevResetPasswordPanelOpacity, resetPasswordPanelOpacity), resetPasswordPanelOpacity, ]); } componentDidMount() { this.props.dispatchActionPromise( handleVerificationCodeActionTypes, this.handleVerificationCodeAction(), ); Keyboard.dismiss(); if (this.props.isForeground) { this.onForeground(); } } componentDidUpdate(prevProps: Props, prevState: State) { if ( this.state.verifyField === verifyField.EMAIL && prevState.verifyField !== verifyField.EMAIL ) { sleep(1500).then(this.dismiss); } const prevCode = prevProps.route.params.verifyCode; const code = this.props.route.params.verifyCode; if (code !== prevCode) { Keyboard.dismiss(); this.nextMode = 'simple-text'; this.modeValue.setValue(modeNumbers[this.nextMode]); this.keyboardHeightValue.setValue(0); this.setState({ mode: this.nextMode, verifyField: null, errorMessage: null, resetPasswordUsername: null, }); this.props.dispatchActionPromise( handleVerificationCodeActionTypes, this.handleVerificationCodeAction(), ); } if (this.props.isForeground && !prevProps.isForeground) { this.onForeground(); } else if (!this.props.isForeground && prevProps.isForeground) { this.onBackground(); } const newContentHeight = this.props.dimensions.safeAreaHeight; const oldContentHeight = prevProps.dimensions.safeAreaHeight; if (newContentHeight !== oldContentHeight) { this.contentHeight.setValue(newContentHeight); } } onForeground() { this.keyboardShowListener = addKeyboardShowListener(this.keyboardShow); this.keyboardHideListener = addKeyboardDismissListener(this.keyboardHide); } onBackground() { if (this.keyboardShowListener) { removeKeyboardListener(this.keyboardShowListener); this.keyboardShowListener = null; } if (this.keyboardHideListener) { removeKeyboardListener(this.keyboardHideListener); this.keyboardHideListener = null; } } dismiss = () => { this.props.navigation.clearRootModals([this.props.route.key]); }; onResetPasswordSuccess = async () => { this.nextMode = 'simple-text'; this.modeValue.setValue(modeNumbers[this.nextMode]); this.keyboardHeightValue.setValue(0); Keyboard.dismiss(); // Wait a couple seconds before letting the SUCCESS action propagate and // clear VerificationModal await sleep(1750); this.dismiss(); }; async handleVerificationCodeAction() { const code = this.props.route.params.verifyCode; try { const result = await this.props.handleVerificationCode(code); if (result.verifyField === verifyField.EMAIL) { this.setState({ verifyField: result.verifyField }); } else if (result.verifyField === verifyField.RESET_PASSWORD) { this.nextMode = 'reset-password'; this.modeValue.setValue(modeNumbers[this.nextMode]); this.keyboardHeightValue.setValue(-1); this.setState({ verifyField: result.verifyField, mode: 'reset-password', resetPasswordUsername: result.resetPasswordUsername, }); } } catch (e) { if (e.message === 'invalid_code') { this.setState({ errorMessage: 'Invalid verification code' }); } else { this.setState({ errorMessage: 'Unknown error occurred' }); } throw e; } } keyboardShow = (event: KeyboardEvent) => { const keyboardHeight = Platform.select({ // Android doesn't include the bottomInset in this height measurement android: event.endCoordinates.height, default: Math.max( event.endCoordinates.height - this.props.dimensions.bottomInset, 0, ), }); this.keyboardHeightValue.setValue(keyboardHeight); }; keyboardHide = () => { if (!this.activeAlert) { this.keyboardHeightValue.setValue(0); } }; setActiveAlert = (activeAlert: boolean) => { this.activeAlert = activeAlert; }; render() { const statusBar = ; const background = ( ); const closeButton = ( ); let content; if (this.state.mode === 'reset-password') { const code = this.props.route.params.verifyCode; invariant(this.state.resetPasswordUsername, 'should be set'); content = ( ); } else if (this.state.errorMessage) { content = ( {this.state.errorMessage} ); } else if (this.state.verifyField !== null) { let message; if (this.state.verifyField === verifyField.EMAIL) { message = 'Thanks for verifying your email!'; } else { message = 'Your password has been reset.'; } content = ( {message} ); } else { content = ( Verifying code... ); } const padding = { paddingTop: this.paddingTopValue }; const animatedContent = ( {content} ); return ( {background} {statusBar} {animatedContent} {closeButton} ); } } const styles = StyleSheet.create({ closeButton: { backgroundColor: '#D0D0D055', borderRadius: 3, height: 36, position: 'absolute', right: 15, top: 15, width: 36, }, closeButtonIcon: { left: 10, position: 'absolute', top: 8, }, container: { backgroundColor: 'transparent', flex: 1, }, contentContainer: { height: 90, }, icon: { textAlign: 'center', }, loadingText: { bottom: 0, color: 'white', fontSize: 20, left: 0, position: 'absolute', right: 0, textAlign: 'center', }, modalBackground: { height: ('100%': number | string), position: 'absolute', width: ('100%': number | string), }, }); registerFetchKey(handleVerificationCodeActionTypes); const isForegroundSelector = createIsForegroundSelector( VerificationModalRouteName, ); export default React.memo(function ConnectedVerificationModal( props: BaseProps, ) { const navContext = React.useContext(NavContext); const isForeground = isForegroundSelector(navContext); const dimensions = useSelector(derivedDimensionsInfoSelector); const splashStyle = useSelector(splashStyleSelector); const dispatchActionPromise = useDispatchActionPromise(); const callHandleVerificationCode = useServerCall(handleVerificationCode); return ( ); }); diff --git a/native/calendar/calendar-input-bar.react.js b/native/calendar/calendar-input-bar.react.js index ee8f63ef5..7182bb29a 100644 --- a/native/calendar/calendar-input-bar.react.js +++ b/native/calendar/calendar-input-bar.react.js @@ -1,55 +1,53 @@ // @flow -import type { AppState } from '../redux/redux-setup'; - +import { connect } from 'lib/utils/redux-utils'; import * as React from 'react'; import { View, Text } from 'react-native'; -import { connect } from 'lib/utils/redux-utils'; - import Button from '../components/button.react'; +import type { AppState } from '../redux/redux-setup'; import { styleSelector } from '../themes/colors'; type Props = {| onSave: () => void, disabled: boolean, // Redux state styles: typeof styles, |}; function CalendarInputBar(props: Props) { const inactiveStyle = props.disabled ? props.styles.inactiveContainer : undefined; return ( ); } const styles = { container: { alignItems: 'flex-end', backgroundColor: 'listInputBar', }, inactiveContainer: { opacity: 0, }, saveButtonText: { color: 'link', fontSize: 16, fontWeight: 'bold', marginRight: 5, padding: 8, }, }; const stylesSelector = styleSelector(styles); export default connect((state: AppState) => ({ styles: stylesSelector(state), }))(CalendarInputBar); diff --git a/native/calendar/calendar.react.js b/native/calendar/calendar.react.js index 4e7256323..fe1d04c6c 100644 --- a/native/calendar/calendar.react.js +++ b/native/calendar/calendar.react.js @@ -1,1094 +1,1093 @@ // @flow +import invariant from 'invariant'; +import { + updateCalendarQueryActionTypes, + updateCalendarQuery, +} from 'lib/actions/entry-actions'; +import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; +import { entryKey } from 'lib/shared/entry-utils'; import type { EntryInfo, CalendarQuery, CalendarQueryUpdateResult, } from 'lib/types/entry-types'; -import type { - CalendarItem, - SectionHeaderItem, - SectionFooterItem, - LoaderItem, -} from '../selectors/calendar-selectors'; -import type { ViewToken } from '../types/react-native'; -import type { KeyboardEvent } from '../keyboard/keyboard'; import type { CalendarFilter } from 'lib/types/filter-types'; import type { LoadingStatus } from 'lib/types/loading-types'; import type { ConnectionStatus } from 'lib/types/socket-types'; import type { ThreadInfo } from 'lib/types/thread-types'; -import type { TabNavigationProp } from '../navigation/app-navigator.react'; -import type { NavigationRoute } from '../navigation/route-names'; - +import { + useServerCall, + useDispatchActionPromise, + type DispatchActionPromise, +} from 'lib/utils/action-utils'; +import { dateString, prettyDate, dateFromString } from 'lib/utils/date-utils'; +import sleep from 'lib/utils/sleep'; +import _filter from 'lodash/fp/filter'; +import _find from 'lodash/fp/find'; +import _findIndex from 'lodash/fp/findIndex'; +import _map from 'lodash/fp/map'; +import _pickBy from 'lodash/fp/pickBy'; +import _size from 'lodash/fp/size'; +import _sum from 'lodash/fp/sum'; +import _throttle from 'lodash/throttle'; import * as React from 'react'; import { View, Text, FlatList, AppState as NativeAppState, Platform, LayoutAnimation, TouchableWithoutFeedback, } from 'react-native'; -import invariant from 'invariant'; -import _findIndex from 'lodash/fp/findIndex'; -import _map from 'lodash/fp/map'; -import _find from 'lodash/fp/find'; -import _filter from 'lodash/fp/filter'; -import _sum from 'lodash/fp/sum'; -import _pickBy from 'lodash/fp/pickBy'; -import _size from 'lodash/fp/size'; -import _throttle from 'lodash/throttle'; import SafeAreaView from 'react-native-safe-area-view'; -import { entryKey } from 'lib/shared/entry-utils'; -import { dateString, prettyDate, dateFromString } from 'lib/utils/date-utils'; -import { - updateCalendarQueryActionTypes, - updateCalendarQuery, -} from 'lib/actions/entry-actions'; -import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; -import sleep from 'lib/utils/sleep'; -import { - useServerCall, - useDispatchActionPromise, - type DispatchActionPromise, -} from 'lib/utils/action-utils'; - -import { - Entry, - InternalEntry, - dummyNodeForEntryHeightMeasurement, -} from './entry.react'; -import { calendarListData } from '../selectors/calendar-selectors'; -import { - createIsForegroundSelector, - createActiveTabSelector, -} from '../navigation/nav-selectors'; -import NodeHeightMeasurer from '../components/node-height-measurer.react'; +import ContentLoading from '../components/content-loading.react'; +import KeyboardAvoidingView from '../components/keyboard-avoiding-view.react'; import ListLoadingIndicator from '../components/list-loading-indicator.react'; -import SectionFooter from './section-footer.react'; -import CalendarInputBar from './calendar-input-bar.react'; +import NodeHeightMeasurer from '../components/node-height-measurer.react'; import { addKeyboardShowListener, addKeyboardDismissListener, removeKeyboardListener, } from '../keyboard/keyboard'; +import type { KeyboardEvent } from '../keyboard/keyboard'; +import type { TabNavigationProp } from '../navigation/app-navigator.react'; +import DisconnectedBar from '../navigation/disconnected-bar.react'; +import { + createIsForegroundSelector, + createActiveTabSelector, +} from '../navigation/nav-selectors'; +import { NavContext } from '../navigation/navigation-context'; import { CalendarRouteName, ThreadPickerModalRouteName, } from '../navigation/route-names'; -import DisconnectedBar from '../navigation/disconnected-bar.react'; +import type { NavigationRoute } from '../navigation/route-names'; +import { useSelector } from '../redux/redux-utils'; +import { calendarListData } from '../selectors/calendar-selectors'; +import type { + CalendarItem, + SectionHeaderItem, + SectionFooterItem, + LoaderItem, +} from '../selectors/calendar-selectors'; +import { + type DerivedDimensionsInfo, + derivedDimensionsInfoSelector, +} from '../selectors/dimensions-selectors'; import { useColors, useStyles, useIndicatorStyle, type Colors, type IndicatorStyle, } from '../themes/colors'; -import ContentLoading from '../components/content-loading.react'; -import { NavContext } from '../navigation/navigation-context'; -import KeyboardAvoidingView from '../components/keyboard-avoiding-view.react'; +import type { ViewToken } from '../types/react-native'; + +import CalendarInputBar from './calendar-input-bar.react'; import { - type DerivedDimensionsInfo, - derivedDimensionsInfoSelector, -} from '../selectors/dimensions-selectors'; -import { useSelector } from '../redux/redux-utils'; + Entry, + InternalEntry, + dummyNodeForEntryHeightMeasurement, +} from './entry.react'; +import SectionFooter from './section-footer.react'; export type EntryInfoWithHeight = {| ...EntryInfo, +textHeight: number, |}; type CalendarItemWithHeight = | LoaderItem | SectionHeaderItem | SectionFooterItem | {| itemType: 'entryInfo', entryInfo: EntryInfoWithHeight, threadInfo: ThreadInfo, |}; type ExtraData = {| +activeEntries: { +[key: string]: boolean }, +visibleEntries: { +[key: string]: boolean }, |}; const safeAreaViewForceInset = { top: 'always', bottom: 'never', }; type BaseProps = {| +navigation: TabNavigationProp<'Calendar'>, +route: NavigationRoute<'Calendar'>, |}; type Props = {| ...BaseProps, // Nav state +calendarActive: boolean, // Redux state +listData: ?$ReadOnlyArray, +startDate: string, +endDate: string, +calendarFilters: $ReadOnlyArray, +dimensions: DerivedDimensionsInfo, +loadingStatus: LoadingStatus, +connectionStatus: ConnectionStatus, +colors: Colors, +styles: typeof unboundStyles, +indicatorStyle: IndicatorStyle, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +updateCalendarQuery: ( calendarQuery: CalendarQuery, reduxAlreadyUpdated?: boolean, ) => Promise, |}; type State = {| +listDataWithHeights: ?$ReadOnlyArray, +readyToShowList: boolean, +extraData: ExtraData, +currentlyEditing: $ReadOnlyArray, |}; class Calendar extends React.PureComponent { flatList: ?FlatList = null; currentState: ?string = NativeAppState.currentState; lastForegrounded = 0; lastCalendarReset = 0; currentScrollPosition: ?number = null; // We don't always want an extraData update to trigger a state update, so we // cache the most recent value as a member here latestExtraData: ExtraData; // For some reason, we have to delay the scrollToToday call after the first // scroll upwards firstScrollComplete = false; // When an entry becomes active, we make a note of its key so that once the // keyboard event happens, we know where to move the scrollPos to lastEntryKeyActive: ?string = null; keyboardShowListener: ?{ +remove: () => void }; keyboardDismissListener: ?{ +remove: () => void }; keyboardShownHeight: ?number = null; // If the query fails, we try it again topLoadingFromScroll: ?CalendarQuery = null; bottomLoadingFromScroll: ?CalendarQuery = null; // We wait until the loaders leave view before letting them be triggered again topLoaderWaitingToLeaveView = true; bottomLoaderWaitingToLeaveView = true; // We keep refs to the entries so CalendarInputBar can save them entryRefs = new Map(); constructor(props: Props) { super(props); this.latestExtraData = { activeEntries: {}, visibleEntries: {}, }; this.state = { listDataWithHeights: null, readyToShowList: false, extraData: this.latestExtraData, currentlyEditing: [], }; } componentDidMount() { NativeAppState.addEventListener('change', this.handleAppStateChange); this.keyboardShowListener = addKeyboardShowListener(this.keyboardShow); this.keyboardDismissListener = addKeyboardDismissListener( this.keyboardDismiss, ); this.props.navigation.addListener('tabPress', this.onTabPress); } componentWillUnmount() { NativeAppState.removeEventListener('change', this.handleAppStateChange); if (this.keyboardShowListener) { removeKeyboardListener(this.keyboardShowListener); this.keyboardShowListener = null; } if (this.keyboardDismissListener) { removeKeyboardListener(this.keyboardDismissListener); this.keyboardDismissListener = null; } this.props.navigation.removeListener('tabPress', this.onTabPress); } handleAppStateChange = (nextAppState: ?string) => { const lastState = this.currentState; this.currentState = nextAppState; if ( !lastState || !lastState.match(/inactive|background/) || this.currentState !== 'active' ) { // We're only handling foregrounding here return; } if (Date.now() - this.lastCalendarReset < 500) { // If the calendar got reset right before this callback triggered, that // indicates we should reset the scroll position this.lastCalendarReset = 0; this.scrollToToday(false); } else { // Otherwise, it's possible that the calendar is about to get reset. We // record a timestamp here so we can scrollToToday there. this.lastForegrounded = Date.now(); } }; onTabPress = () => { if (this.props.navigation.isFocused()) { this.scrollToToday(); } }; componentDidUpdate(prevProps: Props, prevState: State) { if (!this.props.listData && this.props.listData !== prevProps.listData) { this.latestExtraData = { activeEntries: {}, visibleEntries: {}, }; this.setState({ listDataWithHeights: null, readyToShowList: false, extraData: this.latestExtraData, }); this.firstScrollComplete = false; this.topLoaderWaitingToLeaveView = true; this.bottomLoaderWaitingToLeaveView = true; } const { loadingStatus, connectionStatus } = this.props; const { loadingStatus: prevLoadingStatus, connectionStatus: prevConnectionStatus, } = prevProps; if ( (loadingStatus === 'error' && prevLoadingStatus === 'loading') || (connectionStatus === 'connected' && prevConnectionStatus !== 'connected') ) { this.loadMoreAbove(); this.loadMoreBelow(); } const lastLDWH = prevState.listDataWithHeights; const newLDWH = this.state.listDataWithHeights; if (!newLDWH) { return; } else if (!lastLDWH) { if (!this.props.calendarActive) { // FlatList has an initialScrollIndex prop, which is usually close to // centering but can be off when there is a particularly large Entry in // the list. scrollToToday lets us actually center, but gets overriden // by initialScrollIndex if we call it right after the FlatList mounts sleep(50).then(() => this.scrollToToday()); } return; } if (newLDWH.length < lastLDWH.length) { this.topLoaderWaitingToLeaveView = true; this.bottomLoaderWaitingToLeaveView = true; if (this.flatList) { if (!this.props.calendarActive) { // If the currentCalendarQuery gets reset we scroll to the center this.scrollToToday(); } else if (Date.now() - this.lastForegrounded < 500) { // If the app got foregrounded right before the calendar got reset, // that indicates we should reset the scroll position this.lastForegrounded = 0; this.scrollToToday(false); } else { // Otherwise, it's possible that we got triggered before the // foreground callback. Let's record a timestamp here so we can call // scrollToToday there this.lastCalendarReset = Date.now(); } } } const { lastStartDate, newStartDate, lastEndDate, newEndDate, } = Calendar.datesFromListData(lastLDWH, newLDWH); if (newStartDate > lastStartDate || newEndDate < lastEndDate) { // If there are fewer items in our new data, which happens when the // current calendar query gets reset due to inactivity, let's reset the // scroll position to the center (today) if (!this.props.calendarActive) { sleep(50).then(() => this.scrollToToday()); } this.firstScrollComplete = false; } else if (newStartDate < lastStartDate) { this.updateScrollPositionAfterPrepend(lastLDWH, newLDWH); } else if (newEndDate > lastEndDate) { this.firstScrollComplete = true; } else if (newLDWH.length > lastLDWH.length) { LayoutAnimation.easeInEaseOut(); } if (newStartDate < lastStartDate) { this.topLoadingFromScroll = null; } if (newEndDate > lastEndDate) { this.bottomLoadingFromScroll = null; } const { keyboardShownHeight, lastEntryKeyActive } = this; if (keyboardShownHeight && lastEntryKeyActive) { this.scrollToKey(lastEntryKeyActive, keyboardShownHeight); this.lastEntryKeyActive = null; } } static datesFromListData( lastLDWH: $ReadOnlyArray, newLDWH: $ReadOnlyArray, ) { const lastSecondItem = lastLDWH[1]; const newSecondItem = newLDWH[1]; invariant( newSecondItem.itemType === 'header' && lastSecondItem.itemType === 'header', 'second item in listData should be a header', ); const lastStartDate = dateFromString(lastSecondItem.dateString); const newStartDate = dateFromString(newSecondItem.dateString); const lastPenultimateItem = lastLDWH[lastLDWH.length - 2]; const newPenultimateItem = newLDWH[newLDWH.length - 2]; invariant( newPenultimateItem.itemType === 'footer' && lastPenultimateItem.itemType === 'footer', 'penultimate item in listData should be a footer', ); const lastEndDate = dateFromString(lastPenultimateItem.dateString); const newEndDate = dateFromString(newPenultimateItem.dateString); return { lastStartDate, newStartDate, lastEndDate, newEndDate }; } /** * When prepending list items, FlatList isn't smart about preserving scroll * position. If we're at the start of the list before prepending, FlatList * will just keep us at the front after prepending. But we want to preserve * the previous on-screen items, so we have to do a calculation to get the new * scroll position. (And deal with the inherent glitchiness of trying to time * that change with the items getting prepended... *sigh*.) */ updateScrollPositionAfterPrepend( lastLDWH: $ReadOnlyArray, newLDWH: $ReadOnlyArray, ) { const existingKeys = new Set(_map(Calendar.keyExtractor)(lastLDWH)); const newItems = _filter( (item: CalendarItemWithHeight) => !existingKeys.has(Calendar.keyExtractor(item)), )(newLDWH); const heightOfNewItems = Calendar.heightOfItems(newItems); const flatList = this.flatList; invariant(flatList, 'flatList should be set'); const scrollAction = () => { invariant( this.currentScrollPosition !== undefined && this.currentScrollPosition !== null, 'currentScrollPosition should be set', ); const currentScrollPosition = Math.max(this.currentScrollPosition, 0); let offset = currentScrollPosition + heightOfNewItems; flatList.scrollToOffset({ offset, animated: false, }); }; scrollAction(); if (!this.firstScrollComplete) { setTimeout(scrollAction, 0); this.firstScrollComplete = true; } } scrollToToday(animated: ?boolean = undefined) { if (animated === undefined) { animated = this.props.calendarActive; } const ldwh = this.state.listDataWithHeights; if (!ldwh) { return; } const todayIndex = _findIndex(['dateString', dateString(new Date())])(ldwh); invariant(this.flatList, "scrollToToday called, but flatList isn't set"); this.flatList.scrollToIndex({ index: todayIndex, animated, viewPosition: 0.5, }); } renderItem = (row: { item: CalendarItemWithHeight }) => { const item = row.item; if (item.itemType === 'loader') { return ; } else if (item.itemType === 'header') { return this.renderSectionHeader(item); } else if (item.itemType === 'entryInfo') { const key = entryKey(item.entryInfo); return ( ); } else if (item.itemType === 'footer') { return this.renderSectionFooter(item); } invariant(false, 'renderItem conditions should be exhaustive'); }; renderSectionHeader = (item: SectionHeaderItem) => { let date = prettyDate(item.dateString); if (dateString(new Date()) === item.dateString) { date += ' (today)'; } const dateObj = dateFromString(item.dateString).getDay(); const weekendStyle = dateObj === 0 || dateObj === 6 ? this.props.styles.weekendSectionHeader : null; return ( {date} ); }; renderSectionFooter = (item: SectionFooterItem) => { return ( ); }; onAdd = (dayString: string) => { this.props.navigation.navigate(ThreadPickerModalRouteName, { presentedFrom: this.props.route.key, dateString: dayString, }); }; static keyExtractor = (item: CalendarItemWithHeight | CalendarItem) => { if (item.itemType === 'loader') { return item.key; } else if (item.itemType === 'header') { return item.dateString + '/header'; } else if (item.itemType === 'entryInfo') { return entryKey(item.entryInfo); } else if (item.itemType === 'footer') { return item.dateString + '/footer'; } invariant(false, 'keyExtractor conditions should be exhaustive'); }; static getItemLayout( data: ?$ReadOnlyArray, index: number, ) { if (!data) { return { length: 0, offset: 0, index }; } const offset = Calendar.heightOfItems(data.filter((_, i) => i < index)); const item = data[index]; const length = item ? Calendar.itemHeight(item) : 0; return { length, offset, index }; } static itemHeight(item: CalendarItemWithHeight): number { if (item.itemType === 'loader') { return 56; } else if (item.itemType === 'header') { return 31; } else if (item.itemType === 'entryInfo') { const verticalPadding = 10; return verticalPadding + item.entryInfo.textHeight; } else if (item.itemType === 'footer') { return 40; } invariant(false, 'itemHeight conditions should be exhaustive'); } static heightOfItems(data: $ReadOnlyArray): number { return _sum(data.map(Calendar.itemHeight)); } render() { const { listDataWithHeights } = this.state; let flatList = null; if (listDataWithHeights) { const flatListStyle = { opacity: this.state.readyToShowList ? 1 : 0 }; const initialScrollIndex = this.initialScrollIndex(listDataWithHeights); flatList = ( ); } let loadingIndicator = null; if (!listDataWithHeights || !this.state.readyToShowList) { loadingIndicator = ( ); } const disableInputBar = this.state.currentlyEditing.length === 0; return ( <> {loadingIndicator} {flatList} ); } flatListHeight() { const { safeAreaHeight, tabBarHeight } = this.props.dimensions; return safeAreaHeight - tabBarHeight; } initialScrollIndex(data: $ReadOnlyArray) { const todayIndex = _findIndex(['dateString', dateString(new Date())])(data); const heightOfTodayHeader = Calendar.itemHeight(data[todayIndex]); let returnIndex = todayIndex; let heightLeft = (this.flatListHeight() - heightOfTodayHeader) / 2; while (heightLeft > 0) { heightLeft -= Calendar.itemHeight(data[--returnIndex]); } return returnIndex; } flatListRef = (flatList: ?FlatList) => { this.flatList = flatList; }; entryRef = (inEntryKey: string, entry: ?InternalEntry) => { this.entryRefs.set(inEntryKey, entry); }; makeAllEntriesInactive = () => { if (_size(this.state.extraData.activeEntries) === 0) { if (_size(this.latestExtraData.activeEntries) !== 0) { this.latestExtraData = { visibleEntries: this.latestExtraData.visibleEntries, activeEntries: this.state.extraData.activeEntries, }; } return; } this.latestExtraData = { visibleEntries: this.latestExtraData.visibleEntries, activeEntries: {}, }; this.setState({ extraData: this.latestExtraData }); }; makeActive = (key: string, active: boolean) => { if (!active) { const activeKeys = Object.keys(this.latestExtraData.activeEntries); if (activeKeys.length === 0) { if (Object.keys(this.state.extraData.activeEntries).length !== 0) { this.setState({ extraData: this.latestExtraData }); } return; } const activeKey = activeKeys[0]; if (activeKey === key) { this.latestExtraData = { visibleEntries: this.latestExtraData.visibleEntries, activeEntries: {}, }; this.setState({ extraData: this.latestExtraData }); } return; } if ( _size(this.state.extraData.activeEntries) === 1 && this.state.extraData.activeEntries[key] ) { if ( _size(this.latestExtraData.activeEntries) !== 1 || !this.latestExtraData.activeEntries[key] ) { this.latestExtraData = { visibleEntries: this.latestExtraData.visibleEntries, activeEntries: this.state.extraData.activeEntries, }; } return; } this.latestExtraData = { visibleEntries: this.latestExtraData.visibleEntries, activeEntries: { [key]: true }, }; this.setState({ extraData: this.latestExtraData }); }; onEnterEntryEditMode = (entryInfo: EntryInfoWithHeight) => { const key = entryKey(entryInfo); const keyboardShownHeight = this.keyboardShownHeight; if (keyboardShownHeight && this.state.listDataWithHeights) { this.scrollToKey(key, keyboardShownHeight); } else { this.lastEntryKeyActive = key; } const newCurrentlyEditing = [ ...new Set([...this.state.currentlyEditing, key]), ]; if (newCurrentlyEditing.length > this.state.currentlyEditing.length) { this.setState({ currentlyEditing: newCurrentlyEditing }); } }; onConcludeEntryEditMode = (entryInfo: EntryInfoWithHeight) => { const key = entryKey(entryInfo); const newCurrentlyEditing = this.state.currentlyEditing.filter( (k) => k !== key, ); if (newCurrentlyEditing.length < this.state.currentlyEditing.length) { this.setState({ currentlyEditing: newCurrentlyEditing }); } }; keyboardShow = (event: KeyboardEvent) => { // flatListHeight() factors in the size of the tab bar, // but it is hidden by the keyboard since it is at the bottom const { bottomInset, tabBarHeight } = this.props.dimensions; const inputBarHeight = Platform.OS === 'android' ? 37.7 : 35.5; const keyboardHeight = Platform.select({ // Android doesn't include the bottomInset in this height measurement android: event.endCoordinates.height, default: Math.max(event.endCoordinates.height - bottomInset, 0), }); const keyboardShownHeight = inputBarHeight + Math.max(keyboardHeight - tabBarHeight, 0); this.keyboardShownHeight = keyboardShownHeight; const lastEntryKeyActive = this.lastEntryKeyActive; if (lastEntryKeyActive && this.state.listDataWithHeights) { this.scrollToKey(lastEntryKeyActive, keyboardShownHeight); this.lastEntryKeyActive = null; } }; keyboardDismiss = () => { this.keyboardShownHeight = null; }; scrollToKey(lastEntryKeyActive: string, keyboardHeight: number) { const data = this.state.listDataWithHeights; invariant(data, 'should be set'); const index = data.findIndex( (item: CalendarItemWithHeight) => Calendar.keyExtractor(item) === lastEntryKeyActive, ); if (index === -1) { return; } const itemStart = Calendar.heightOfItems(data.filter((_, i) => i < index)); const itemHeight = Calendar.itemHeight(data[index]); const entryAdditionalActiveHeight = Platform.OS === 'android' ? 21 : 20; const itemEnd = itemStart + itemHeight + entryAdditionalActiveHeight; const visibleHeight = this.flatListHeight() - keyboardHeight; if ( this.currentScrollPosition !== undefined && this.currentScrollPosition !== null && itemStart > this.currentScrollPosition && itemEnd < this.currentScrollPosition + visibleHeight ) { return; } const offset = itemStart - (visibleHeight - itemHeight) / 2; invariant(this.flatList, 'flatList should be set'); this.flatList.scrollToOffset({ offset, animated: true }); } heightMeasurerKey = (item: CalendarItem) => { if (item.itemType !== 'entryInfo') { return null; } return item.entryInfo.text; }; heightMeasurerDummy = (item: CalendarItem) => { invariant( item.itemType === 'entryInfo', 'NodeHeightMeasurer asked for dummy for non-entryInfo item', ); return dummyNodeForEntryHeightMeasurement(item.entryInfo.text); }; heightMeasurerMergeItem = (item: CalendarItem, height: ?number) => { if (item.itemType !== 'entryInfo') { return item; } invariant(height !== null && height !== undefined, 'height should be set'); const { entryInfo } = item; return { itemType: 'entryInfo', entryInfo: Calendar.entryInfoWithHeight(entryInfo, height), threadInfo: item.threadInfo, }; }; static entryInfoWithHeight( entryInfo: EntryInfo, textHeight: number, ): EntryInfoWithHeight { // Blame Flow for not accepting object spread on exact types if (entryInfo.id && entryInfo.localID) { return { id: entryInfo.id, localID: entryInfo.localID, threadID: entryInfo.threadID, text: entryInfo.text, year: entryInfo.year, month: entryInfo.month, day: entryInfo.day, creationTime: entryInfo.creationTime, creator: entryInfo.creator, deleted: entryInfo.deleted, textHeight: Math.ceil(textHeight), }; } else if (entryInfo.id) { return { id: entryInfo.id, threadID: entryInfo.threadID, text: entryInfo.text, year: entryInfo.year, month: entryInfo.month, day: entryInfo.day, creationTime: entryInfo.creationTime, creator: entryInfo.creator, deleted: entryInfo.deleted, textHeight: Math.ceil(textHeight), }; } else { return { localID: entryInfo.localID, threadID: entryInfo.threadID, text: entryInfo.text, year: entryInfo.year, month: entryInfo.month, day: entryInfo.day, creationTime: entryInfo.creationTime, creator: entryInfo.creator, deleted: entryInfo.deleted, textHeight: Math.ceil(textHeight), }; } } allHeightsMeasured = ( listDataWithHeights: $ReadOnlyArray, ) => { this.setState({ listDataWithHeights }); }; onViewableItemsChanged = (info: { viewableItems: ViewToken[], changed: ViewToken[], }) => { const ldwh = this.state.listDataWithHeights; if (!ldwh) { // This indicates the listData was cleared (set to null) right before this // callback was called. Since this leads to the FlatList getting cleared, // we'll just ignore this callback. return; } const visibleEntries = {}; for (let token of info.viewableItems) { if (token.item.itemType === 'entryInfo') { visibleEntries[entryKey(token.item.entryInfo)] = true; } } this.latestExtraData = { activeEntries: _pickBy((_, key: string) => { if (visibleEntries[key]) { return true; } // We don't automatically set scrolled-away entries to be inactive // because entries can be out-of-view at creation time if they need to // be scrolled into view (see onEnterEntryEditMode). If Entry could // distinguish the reasons its active prop gets set to false, it could // differentiate the out-of-view case from the something-pressed case, // and then we could set scrolled-away entries to be inactive without // worrying about this edge case. Until then... const foundItem = _find( (item) => item.entryInfo && entryKey(item.entryInfo) === key, )(ldwh); return !!foundItem; })(this.latestExtraData.activeEntries), visibleEntries, }; const topLoader = _find({ key: 'TopLoader' })(info.viewableItems); if (this.topLoaderWaitingToLeaveView && !topLoader) { this.topLoaderWaitingToLeaveView = false; this.topLoadingFromScroll = null; } const bottomLoader = _find({ key: 'BottomLoader' })(info.viewableItems); if (this.bottomLoaderWaitingToLeaveView && !bottomLoader) { this.bottomLoaderWaitingToLeaveView = false; this.bottomLoadingFromScroll = null; } if ( !this.state.readyToShowList && !this.topLoaderWaitingToLeaveView && !this.bottomLoaderWaitingToLeaveView && info.viewableItems.length > 0 ) { this.setState({ readyToShowList: true, extraData: this.latestExtraData, }); } if ( topLoader && !this.topLoaderWaitingToLeaveView && !this.topLoadingFromScroll ) { this.topLoaderWaitingToLeaveView = true; const start = dateFromString(this.props.startDate); start.setDate(start.getDate() - 31); const startDate = dateString(start); const endDate = this.props.endDate; this.topLoadingFromScroll = { startDate, endDate, filters: this.props.calendarFilters, }; this.loadMoreAbove(); } else if ( bottomLoader && !this.bottomLoaderWaitingToLeaveView && !this.bottomLoadingFromScroll ) { this.bottomLoaderWaitingToLeaveView = true; const end = dateFromString(this.props.endDate); end.setDate(end.getDate() + 31); const endDate = dateString(end); const startDate = this.props.startDate; this.bottomLoadingFromScroll = { startDate, endDate, filters: this.props.calendarFilters, }; this.loadMoreBelow(); } }; dispatchCalendarQueryUpdate(calendarQuery: CalendarQuery) { this.props.dispatchActionPromise( updateCalendarQueryActionTypes, this.props.updateCalendarQuery(calendarQuery), ); } loadMoreAbove = _throttle(() => { if ( this.topLoadingFromScroll && this.topLoaderWaitingToLeaveView && this.props.connectionStatus === 'connected' ) { this.dispatchCalendarQueryUpdate(this.topLoadingFromScroll); } }, 1000); loadMoreBelow = _throttle(() => { if ( this.bottomLoadingFromScroll && this.bottomLoaderWaitingToLeaveView && this.props.connectionStatus === 'connected' ) { this.dispatchCalendarQueryUpdate(this.bottomLoadingFromScroll); } }, 1000); onScroll = (event: { +nativeEvent: { +contentOffset: { +y: number } } }) => { this.currentScrollPosition = event.nativeEvent.contentOffset.y; }; // When the user "flicks" the scroll view, this callback gets triggered after // the scrolling ends onMomentumScrollEnd = () => { this.setState({ extraData: this.latestExtraData }); }; // This callback gets triggered when the user lets go of scrolling the scroll // view, regardless of whether it was a "flick" or a pan onScrollEndDrag = () => { // We need to figure out if this was a flick or not. If it's a flick, we'll // let onMomentumScrollEnd handle it once scroll position stabilizes const currentScrollPosition = this.currentScrollPosition; setTimeout(() => { if (this.currentScrollPosition === currentScrollPosition) { this.setState({ extraData: this.latestExtraData }); } }, 50); }; onSaveEntry = () => { const entryKeys = Object.keys(this.latestExtraData.activeEntries); if (entryKeys.length === 0) { return; } const entryRef = this.entryRefs.get(entryKeys[0]); if (entryRef) { entryRef.completeEdit(); } }; } const unboundStyles = { container: { backgroundColor: 'listBackground', flex: 1, }, flatList: { backgroundColor: 'listBackground', flex: 1, }, keyboardAvoidingViewContainer: { position: 'absolute', top: 0, bottom: 0, left: 0, right: 0, }, keyboardAvoidingView: { position: 'absolute', left: 0, right: 0, bottom: 0, }, sectionHeader: { backgroundColor: 'listSeparator', borderBottomWidth: 2, borderColor: 'listBackground', height: 31, }, sectionHeaderText: { color: 'listSeparatorLabel', fontWeight: 'bold', padding: 5, }, weekendSectionHeader: {}, }; const loadingStatusSelector = createLoadingStatusSelector( updateCalendarQueryActionTypes, ); const activeTabSelector = createActiveTabSelector(CalendarRouteName); const activeThreadPickerSelector = createIsForegroundSelector( ThreadPickerModalRouteName, ); export default React.memo(function ConnectedCalendar( props: BaseProps, ) { const navContext = React.useContext(NavContext); const calendarActive = activeTabSelector(navContext) || activeThreadPickerSelector(navContext); const listData = useSelector(calendarListData); const startDate = useSelector((state) => state.navInfo.startDate); const endDate = useSelector((state) => state.navInfo.endDate); const calendarFilters = useSelector((state) => state.calendarFilters); const dimensions = useSelector(derivedDimensionsInfoSelector); const loadingStatus = useSelector(loadingStatusSelector); const connectionStatus = useSelector((state) => state.connection.status); const colors = useColors(); const styles = useStyles(unboundStyles); const indicatorStyle = useIndicatorStyle(); const dispatchActionPromise = useDispatchActionPromise(); const callUpdateCalendarQuery = useServerCall(updateCalendarQuery); return ( ); }); diff --git a/native/calendar/entry.react.js b/native/calendar/entry.react.js index 9aa59a5e9..a48ec6b5c 100644 --- a/native/calendar/entry.react.js +++ b/native/calendar/entry.react.js @@ -1,792 +1,791 @@ // @flow -import type { EntryInfoWithHeight } from './calendar.react'; +import invariant from 'invariant'; +import { + createEntryActionTypes, + createEntry, + saveEntryActionTypes, + saveEntry, + deleteEntryActionTypes, + deleteEntry, + concurrentModificationResetActionType, +} from 'lib/actions/entry-actions'; +import { registerFetchKey } from 'lib/reducers/loading-reducer'; +import { entryKey } from 'lib/shared/entry-utils'; +import { colorIsDark, threadHasPermission } from 'lib/shared/thread-utils'; import type { CreateEntryInfo, SaveEntryInfo, SaveEntryResponse, CreateEntryPayload, DeleteEntryInfo, DeleteEntryResponse, CalendarQuery, } from 'lib/types/entry-types'; -import { type ThreadInfo, threadPermissions } from 'lib/types/thread-types'; import type { LoadingStatus } from 'lib/types/loading-types'; -import type { LayoutEvent } from '../types/react-native'; -import type { TabNavigationProp } from '../navigation/app-navigator.react'; import type { Dispatch } from 'lib/types/redux-types'; - +import { type ThreadInfo, threadPermissions } from 'lib/types/thread-types'; +import { + useServerCall, + useDispatchActionPromise, + type DispatchActionPromise, +} from 'lib/utils/action-utils'; +import { dateString } from 'lib/utils/date-utils'; +import { ServerError } from 'lib/utils/errors'; +import sleep from 'lib/utils/sleep'; +import _isEqual from 'lodash/fp/isEqual'; +import _omit from 'lodash/fp/omit'; import * as React from 'react'; import { View, Text, TextInput, Platform, TouchableWithoutFeedback, Alert, LayoutAnimation, Keyboard, } from 'react-native'; -import invariant from 'invariant'; -import shallowequal from 'shallowequal'; -import _omit from 'lodash/fp/omit'; -import _isEqual from 'lodash/fp/isEqual'; import Icon from 'react-native-vector-icons/FontAwesome'; -import tinycolor from 'tinycolor2'; import { useDispatch } from 'react-redux'; - -import { colorIsDark, threadHasPermission } from 'lib/shared/thread-utils'; -import { - createEntryActionTypes, - createEntry, - saveEntryActionTypes, - saveEntry, - deleteEntryActionTypes, - deleteEntry, - concurrentModificationResetActionType, -} from 'lib/actions/entry-actions'; -import { ServerError } from 'lib/utils/errors'; -import { entryKey } from 'lib/shared/entry-utils'; -import { registerFetchKey } from 'lib/reducers/loading-reducer'; -import { dateString } from 'lib/utils/date-utils'; -import sleep from 'lib/utils/sleep'; -import { - useServerCall, - useDispatchActionPromise, - type DispatchActionPromise, -} from 'lib/utils/action-utils'; +import shallowequal from 'shallowequal'; +import tinycolor from 'tinycolor2'; import Button from '../components/button.react'; -import { - MessageListRouteName, - ThreadPickerModalRouteName, -} from '../navigation/route-names'; +import { SingleLine } from '../components/single-line.react'; +import Markdown from '../markdown/markdown.react'; +import { inlineMarkdownRules } from '../markdown/rules.react'; +import type { TabNavigationProp } from '../navigation/app-navigator.react'; import { createIsForegroundSelector, nonThreadCalendarQuery, } from '../navigation/nav-selectors'; -import LoadingIndicator from './loading-indicator.react'; -import { colors, useStyles } from '../themes/colors'; import { NavContext } from '../navigation/navigation-context'; -import { waitForInteractions } from '../utils/timers'; -import Markdown from '../markdown/markdown.react'; -import { inlineMarkdownRules } from '../markdown/rules.react'; -import { SingleLine } from '../components/single-line.react'; +import { + MessageListRouteName, + ThreadPickerModalRouteName, +} from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils'; +import { colors, useStyles } from '../themes/colors'; +import type { LayoutEvent } from '../types/react-native'; +import { waitForInteractions } from '../utils/timers'; + +import type { EntryInfoWithHeight } from './calendar.react'; +import LoadingIndicator from './loading-indicator.react'; function hueDistance(firstColor: string, secondColor: string): number { const firstHue = tinycolor(firstColor).toHsv().h; const secondHue = tinycolor(secondColor).toHsv().h; const distance = Math.abs(firstHue - secondHue); return distance > 180 ? 360 - distance : distance; } const omitEntryInfo = _omit(['entryInfo']); function dummyNodeForEntryHeightMeasurement(entryText: string) { const text = entryText === '' ? ' ' : entryText; return ( {text} ); } type BaseProps = {| +navigation: TabNavigationProp<'Calendar'>, +entryInfo: EntryInfoWithHeight, +threadInfo: ThreadInfo, +visible: boolean, +active: boolean, +makeActive: (entryKey: string, active: boolean) => void, +onEnterEditMode: (entryInfo: EntryInfoWithHeight) => void, +onConcludeEditMode: (entryInfo: EntryInfoWithHeight) => void, +onPressWhitespace: () => void, +entryRef: (entryKey: string, entry: ?InternalEntry) => void, |}; type Props = {| ...BaseProps, // Redux state +calendarQuery: () => CalendarQuery, +online: boolean, +styles: typeof unboundStyles, // Nav state +threadPickerActive: boolean, // Redux dispatch functions +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +createEntry: (info: CreateEntryInfo) => Promise, +saveEntry: (info: SaveEntryInfo) => Promise, +deleteEntry: (info: DeleteEntryInfo) => Promise, |}; type State = {| +editing: boolean, +text: string, +loadingStatus: LoadingStatus, +height: number, |}; class InternalEntry extends React.Component { textInput: ?React.ElementRef; creating = false; needsUpdateAfterCreation = false; needsDeleteAfterCreation = false; nextSaveAttemptIndex = 0; mounted = false; deleted = false; currentlySaving: ?string; constructor(props: Props) { super(props); this.state = { editing: false, text: props.entryInfo.text, loadingStatus: 'inactive', height: props.entryInfo.textHeight, }; this.state = { ...this.state, editing: InternalEntry.isActive(props, this.state), }; } guardedSetState(input: $Shape) { if (this.mounted) { this.setState(input); } } shouldComponentUpdate(nextProps: Props, nextState: State) { return ( !shallowequal(nextState, this.state) || !shallowequal(omitEntryInfo(nextProps), omitEntryInfo(this.props)) || !_isEqual(nextProps.entryInfo)(this.props.entryInfo) ); } componentDidUpdate(prevProps: Props, prevState: State) { const wasActive = InternalEntry.isActive(prevProps, prevState); const isActive = InternalEntry.isActive(this.props, this.state); if ( !isActive && (this.props.entryInfo.text !== prevProps.entryInfo.text || this.props.entryInfo.textHeight !== prevProps.entryInfo.textHeight) && (this.props.entryInfo.text !== this.state.text || this.props.entryInfo.textHeight !== this.state.height) ) { this.guardedSetState({ text: this.props.entryInfo.text, height: this.props.entryInfo.textHeight, }); this.currentlySaving = null; } if ( !this.props.active && this.state.text === prevState.text && this.state.height !== prevState.height && this.state.height !== this.props.entryInfo.textHeight ) { const approxMeasuredHeight = Math.round(this.state.height * 1000) / 1000; const approxExpectedHeight = Math.round(this.props.entryInfo.textHeight * 1000) / 1000; console.log( `Entry height for ${entryKey(this.props.entryInfo)} was expected to ` + `be ${approxExpectedHeight} but is actually ` + `${approxMeasuredHeight}. This means Calendar's FlatList isn't ` + 'getting the right item height for some of its nodes, which is ' + 'guaranteed to cause glitchy behavior. Please investigate!!', ); } // Our parent will set the active prop to false if something else gets // pressed or if the Entry is scrolled out of view. In either of those cases // we should complete the edit process. if (!this.props.active && prevProps.active) { this.completeEdit(); } if (this.state.height !== prevState.height || isActive !== wasActive) { LayoutAnimation.easeInEaseOut(); } if ( this.props.online && !prevProps.online && this.state.loadingStatus === 'error' ) { this.save(); } if ( this.state.editing && prevState.editing && (this.state.text.trim() === '') !== (prevState.text.trim() === '') ) { LayoutAnimation.easeInEaseOut(); } } componentDidMount() { this.mounted = true; this.props.entryRef(entryKey(this.props.entryInfo), this); } componentWillUnmount() { this.mounted = false; this.props.entryRef(entryKey(this.props.entryInfo), null); this.props.onConcludeEditMode(this.props.entryInfo); } static isActive(props: Props, state: State) { return ( props.active || state.editing || !props.entryInfo.id || state.loadingStatus !== 'inactive' ); } render() { const active = InternalEntry.isActive(this.props, this.state); const { editing } = this.state; const threadColor = `#${this.props.threadInfo.color}`; const darkColor = colorIsDark(this.props.threadInfo.color); let actionLinks = null; if (active) { const actionLinksColor = darkColor ? '#D3D3D3' : '#404040'; const actionLinksTextStyle = { color: actionLinksColor }; const { modalIosHighlightUnderlay: actionLinksUnderlayColor } = darkColor ? colors.dark : colors.light; const loadingIndicatorCanUseRed = hueDistance('red', threadColor) > 50; let editButtonContent = null; if (editing && this.state.text.trim() === '') { // nothing } else if (editing) { editButtonContent = ( SAVE ); } else { editButtonContent = ( EDIT ); } actionLinks = ( ); } const textColor = darkColor ? 'white' : 'black'; let textInput; if (editing) { const textInputStyle = { color: textColor, backgroundColor: threadColor, }; const selectionColor = darkColor ? '#129AFF' : '#036AFF'; textInput = ( ); } let rawText = this.state.text; if (rawText === '' || rawText.slice(-1) === '\n') { rawText += ' '; } const textStyle = { ...this.props.styles.text, color: textColor, opacity: textInput ? 0 : 1, }; // We use an empty View to set the height of the entry, and then position // the Text and TextInput absolutely. This allows to measure height changes // to the Text while controlling the actual height of the entry. const heightStyle = { height: this.state.height }; const entryStyle = { backgroundColor: threadColor }; const opacity = editing ? 1.0 : 0.6; const canEditEntry = threadHasPermission( this.props.threadInfo, threadPermissions.EDIT_ENTRIES, ); return ( ); } textInputRef = (textInput: ?React.ElementRef) => { this.textInput = textInput; if (textInput && this.state.editing) { this.enterEditMode(); } }; enterEditMode = async () => { this.setActive(); this.props.onEnterEditMode(this.props.entryInfo); if (Platform.OS === 'android') { // For some reason if we don't do this the scroll stops halfway through await waitForInteractions(); await sleep(15); } this.focus(); }; focus = () => { const { textInput } = this; if (!textInput) { return; } textInput.focus(); }; onFocus = () => { if (this.props.threadPickerActive) { this.props.navigation.goBack(); } }; setActive = () => this.makeActive(true); completeEdit = () => { // This gets called from CalendarInputBar (save button above keyboard), // onPressEdit (save button in Entry action links), and in // componentDidUpdate above when Calendar sets this Entry to inactive. // Calendar does this if something else gets pressed or the Entry is // scrolled out of view. Note that an Entry won't consider itself inactive // until it's done updating the server with its state, and if the network // requests fail it may stay "active". if (this.textInput) { this.textInput.blur(); } this.onBlur(); }; onBlur = () => { if (this.state.text.trim() === '') { this.delete(); } else if (this.props.entryInfo.text !== this.state.text) { this.save(); } this.guardedSetState({ editing: false }); this.makeActive(false); this.props.onConcludeEditMode(this.props.entryInfo); }; save = () => { this.dispatchSave(this.props.entryInfo.id, this.state.text); }; onTextContainerLayout = (event: LayoutEvent) => { this.guardedSetState({ height: Math.ceil(event.nativeEvent.layout.height), }); }; onChangeText = (newText: string) => { this.guardedSetState({ text: newText }); }; makeActive(active: boolean) { const { threadInfo } = this.props; if (!threadHasPermission(threadInfo, threadPermissions.EDIT_ENTRIES)) { return; } this.props.makeActive(entryKey(this.props.entryInfo), active); } dispatchSave(serverID: ?string, newText: string) { if (this.currentlySaving === newText) { return; } this.currentlySaving = newText; if (newText.trim() === '') { // We don't save the empty string, since as soon as the element becomes // inactive it'll get deleted return; } if (!serverID) { if (this.creating) { // We need the first save call to return so we know the ID of the entry // we're updating, so we'll need to handle this save later this.needsUpdateAfterCreation = true; return; } else { this.creating = true; } } this.guardedSetState({ loadingStatus: 'loading' }); if (!serverID) { this.props.dispatchActionPromise( createEntryActionTypes, this.createAction(newText), ); } else { this.props.dispatchActionPromise( saveEntryActionTypes, this.saveAction(serverID, newText), ); } } async createAction(text: string) { const localID = this.props.entryInfo.localID; invariant(localID, "if there's no serverID, there should be a localID"); const curSaveAttempt = this.nextSaveAttemptIndex++; try { const response = await this.props.createEntry({ text, timestamp: this.props.entryInfo.creationTime, date: dateString( this.props.entryInfo.year, this.props.entryInfo.month, this.props.entryInfo.day, ), threadID: this.props.entryInfo.threadID, localID, calendarQuery: this.props.calendarQuery(), }); if (curSaveAttempt + 1 === this.nextSaveAttemptIndex) { this.guardedSetState({ loadingStatus: 'inactive' }); } this.creating = false; if (this.needsUpdateAfterCreation) { this.needsUpdateAfterCreation = false; this.dispatchSave(response.entryID, this.state.text); } if (this.needsDeleteAfterCreation) { this.needsDeleteAfterCreation = false; this.dispatchDelete(response.entryID); } return response; } catch (e) { if (curSaveAttempt + 1 === this.nextSaveAttemptIndex) { this.guardedSetState({ loadingStatus: 'error' }); } this.currentlySaving = null; this.creating = false; throw e; } } async saveAction(entryID: string, newText: string) { const curSaveAttempt = this.nextSaveAttemptIndex++; try { const response = await this.props.saveEntry({ entryID, text: newText, prevText: this.props.entryInfo.text, timestamp: Date.now(), calendarQuery: this.props.calendarQuery(), }); if (curSaveAttempt + 1 === this.nextSaveAttemptIndex) { this.guardedSetState({ loadingStatus: 'inactive' }); } return { ...response, threadID: this.props.entryInfo.threadID }; } catch (e) { if (curSaveAttempt + 1 === this.nextSaveAttemptIndex) { this.guardedSetState({ loadingStatus: 'error' }); } this.currentlySaving = null; if (e instanceof ServerError && e.message === 'concurrent_modification') { const revertedText = e.payload.db; const onRefresh = () => { this.guardedSetState({ loadingStatus: 'inactive', text: revertedText, }); this.props.dispatch({ type: concurrentModificationResetActionType, payload: { id: entryID, dbText: revertedText }, }); }; Alert.alert( 'Concurrent modification', 'It looks like somebody is attempting to modify that field at the ' + 'same time as you! Please try again.', [{ text: 'OK', onPress: onRefresh }], { cancelable: false }, ); } throw e; } } delete = () => { this.dispatchDelete(this.props.entryInfo.id); }; onPressEdit = () => { if (this.state.editing) { this.completeEdit(); } else { this.guardedSetState({ editing: true }); } }; dispatchDelete(serverID: ?string) { if (this.deleted) { return; } this.deleted = true; LayoutAnimation.easeInEaseOut(); const { localID } = this.props.entryInfo; this.props.dispatchActionPromise( deleteEntryActionTypes, this.deleteAction(serverID), undefined, { localID, serverID }, ); } async deleteAction(serverID: ?string) { if (serverID) { return await this.props.deleteEntry({ entryID: serverID, prevText: this.props.entryInfo.text, calendarQuery: this.props.calendarQuery(), }); } else if (this.creating) { this.needsDeleteAfterCreation = true; } return null; } onPressThreadName = () => { Keyboard.dismiss(); const { threadInfo } = this.props; this.props.navigation.navigate({ name: MessageListRouteName, params: { threadInfo }, key: `${MessageListRouteName}${threadInfo.id}`, }); }; } const unboundStyles = { actionLinks: { flex: 1, flexDirection: 'row', justifyContent: 'space-between', marginTop: -5, }, button: { padding: 5, }, buttonContents: { flex: 1, flexDirection: 'row', }, container: { backgroundColor: 'listBackground', }, entry: { borderRadius: 8, margin: 5, overflow: 'hidden', }, leftLinks: { flex: 1, flexDirection: 'row', justifyContent: 'flex-start', paddingHorizontal: 5, }, leftLinksText: { fontSize: 12, fontWeight: 'bold', paddingLeft: 5, }, pencilIcon: { lineHeight: 13, paddingTop: 1, }, rightLinks: { flex: 1, flexDirection: 'row', justifyContent: 'flex-end', paddingHorizontal: 5, }, rightLinksText: { fontSize: 12, fontWeight: 'bold', }, text: { fontFamily: 'System', fontSize: 16, }, textContainer: { position: 'absolute', top: 0, paddingBottom: 6, paddingLeft: 10, paddingRight: 10, paddingTop: 5, }, textInput: { fontFamily: 'System', fontSize: 16, left: Platform.OS === 'android' ? 9.8 : 10, margin: 0, padding: 0, position: 'absolute', right: 10, top: Platform.OS === 'android' ? 4.8 : 0.5, }, }; registerFetchKey(saveEntryActionTypes); registerFetchKey(deleteEntryActionTypes); const activeThreadPickerSelector = createIsForegroundSelector( ThreadPickerModalRouteName, ); const Entry = React.memo(function ConnectedEntry(props: BaseProps) { const navContext = React.useContext(NavContext); const threadPickerActive = activeThreadPickerSelector(navContext); const calendarQuery = useSelector((state) => nonThreadCalendarQuery({ redux: state, navContext, }), ); const online = useSelector( (state) => state.connection.status === 'connected', ); const styles = useStyles(unboundStyles); const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const callCreateEntry = useServerCall(createEntry); const callSaveEntry = useServerCall(saveEntry); const callDeleteEntry = useServerCall(deleteEntry); return ( ); }); export { InternalEntry, Entry, dummyNodeForEntryHeightMeasurement }; diff --git a/native/calendar/loading-indicator.react.js b/native/calendar/loading-indicator.react.js index 207e7d839..b79aad348 100644 --- a/native/calendar/loading-indicator.react.js +++ b/native/calendar/loading-indicator.react.js @@ -1,34 +1,33 @@ // @flow import type { LoadingStatus } from 'lib/types/loading-types'; - import * as React from 'react'; import { ActivityIndicator, StyleSheet, Platform } from 'react-native'; import Icon from 'react-native-vector-icons/Feather'; type Props = {| loadingStatus: LoadingStatus, color: string, canUseRed: boolean, |}; function LoadingIndicator(props: Props) { if (props.loadingStatus === 'error') { const colorStyle = props.canUseRed ? { color: 'red' } : { color: props.color }; return ; } else if (props.loadingStatus === 'loading') { return ; } else { return null; } } const styles = StyleSheet.create({ errorIcon: { fontSize: 16, paddingTop: Platform.OS === 'android' ? 6 : 4, }, }); export default LoadingIndicator; diff --git a/native/calendar/section-footer.react.js b/native/calendar/section-footer.react.js index 7086c0137..ceb70ad1f 100644 --- a/native/calendar/section-footer.react.js +++ b/native/calendar/section-footer.react.js @@ -1,98 +1,96 @@ // @flow -import type { AppState } from '../redux/redux-setup'; - +import { connect } from 'lib/utils/redux-utils'; +import PropTypes from 'prop-types'; import * as React from 'react'; import { View, Text, TouchableWithoutFeedback } from 'react-native'; import Icon from 'react-native-vector-icons/FontAwesome'; -import PropTypes from 'prop-types'; - -import { connect } from 'lib/utils/redux-utils'; import Button from '../components/button.react'; +import type { AppState } from '../redux/redux-setup'; import { type Colors, colorsPropType, colorsSelector, styleSelector, } from '../themes/colors'; type Props = {| dateString: string, onAdd: (dateString: string) => void, onPressWhitespace: () => void, // Redux state colors: Colors, styles: typeof styles, |}; class SectionFooter extends React.PureComponent { static propTypes = { dateString: PropTypes.string.isRequired, onAdd: PropTypes.func.isRequired, onPressWhitespace: PropTypes.func.isRequired, colors: colorsPropType.isRequired, styles: PropTypes.objectOf(PropTypes.object).isRequired, }; render() { const { modalIosHighlightUnderlay: underlayColor } = this.props.colors; return ( ); } onSubmit = () => { this.props.onAdd(this.props.dateString); }; } const styles = { actionLinksText: { color: 'listSeparatorLabel', fontWeight: 'bold', }, addButton: { backgroundColor: 'listSeparator', borderRadius: 5, margin: 5, paddingBottom: 5, paddingLeft: 10, paddingRight: 10, paddingTop: 5, }, addButtonContents: { alignItems: 'center', flexDirection: 'row', }, addIcon: { color: 'listSeparatorLabel', fontSize: 14, paddingRight: 6, }, sectionFooter: { alignItems: 'flex-start', backgroundColor: 'listBackground', height: 40, }, }; const stylesSelector = styleSelector(styles); export default connect((state: AppState) => ({ colors: colorsSelector(state), styles: stylesSelector(state), }))(SectionFooter); diff --git a/native/calendar/thread-picker-modal.react.js b/native/calendar/thread-picker-modal.react.js index 29c2c9319..e5511118f 100644 --- a/native/calendar/thread-picker-modal.react.js +++ b/native/calendar/thread-picker-modal.react.js @@ -1,100 +1,98 @@ // @flow -import type { RootNavigationProp } from '../navigation/root-navigator.react'; -import type { NavigationRoute } from '../navigation/route-names'; - -import * as React from 'react'; -import { StyleSheet } from 'react-native'; import invariant from 'invariant'; -import { useDispatch } from 'react-redux'; - -import { onScreenEntryEditableThreadInfos } from 'lib/selectors/thread-selectors'; import { createLocalEntry, createLocalEntryActionType, } from 'lib/actions/entry-actions'; import { threadSearchIndex } from 'lib/selectors/nav-selectors'; +import { onScreenEntryEditableThreadInfos } from 'lib/selectors/thread-selectors'; +import * as React from 'react'; +import { StyleSheet } from 'react-native'; +import { useDispatch } from 'react-redux'; import Modal from '../components/modal.react'; import ThreadList from '../components/thread-list.react'; import { RootNavigatorContext } from '../navigation/root-navigator-context'; -import { waitForInteractions } from '../utils/timers'; +import type { RootNavigationProp } from '../navigation/root-navigator.react'; +import type { NavigationRoute } from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils'; +import { waitForInteractions } from '../utils/timers'; export type ThreadPickerModalParams = {| presentedFrom: string, dateString: string, |}; type Props = {| navigation: RootNavigationProp<'ThreadPickerModal'>, route: NavigationRoute<'ThreadPickerModal'>, |}; function ThreadPickerModal(props: Props) { const { navigation, route: { params: { dateString }, }, } = props; const viewerID = useSelector( (state) => state.currentUserInfo && state.currentUserInfo.id, ); const nextLocalID = useSelector((state) => state.nextLocalID); const dispatch = useDispatch(); const rootNavigatorContext = React.useContext(RootNavigatorContext); const threadPicked = React.useCallback( (threadID: string) => { invariant( dateString && viewerID && rootNavigatorContext, 'inputs to threadPicked should be set', ); rootNavigatorContext.setKeyboardHandlingEnabled(false); dispatch({ type: createLocalEntryActionType, payload: createLocalEntry(threadID, nextLocalID, dateString, viewerID), }); }, [rootNavigatorContext, dispatch, viewerID, nextLocalID, dateString], ); React.useEffect( () => navigation.addListener('blur', async () => { await waitForInteractions(); invariant( rootNavigatorContext, 'RootNavigatorContext should be set in onScreenBlur', ); rootNavigatorContext.setKeyboardHandlingEnabled(true); }), [navigation, rootNavigatorContext], ); const index = useSelector((state) => threadSearchIndex(state)); const onScreenThreadInfos = useSelector((state) => onScreenEntryEditableThreadInfos(state), ); return ( ); } const styles = StyleSheet.create({ threadListItem: { paddingLeft: 10, paddingRight: 10, paddingVertical: 2, }, }); export default ThreadPickerModal; diff --git a/native/chat/background-chat-thread-list.react.js b/native/chat/background-chat-thread-list.react.js index c7379e745..389726ca5 100644 --- a/native/chat/background-chat-thread-list.react.js +++ b/native/chat/background-chat-thread-list.react.js @@ -1,66 +1,65 @@ // @flow -import type { ChatTopTabsNavigationProp } from './chat.react'; -import type { NavigationRoute } from '../navigation/route-names'; - -import * as React from 'react'; -import { Text } from 'react-native'; - +import { unreadBackgroundCount } from 'lib/selectors/thread-selectors'; import { threadInBackgroundChatList, emptyItemText, } from 'lib/shared/thread-utils'; -import { unreadBackgroundCount } from 'lib/selectors/thread-selectors'; +import * as React from 'react'; +import { Text } from 'react-native'; -import ChatThreadList from './chat-thread-list.react'; -import { useStyles } from '../themes/colors'; +import type { NavigationRoute } from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils'; +import { useStyles } from '../themes/colors'; + +import ChatThreadList from './chat-thread-list.react'; +import type { ChatTopTabsNavigationProp } from './chat.react'; type BackgroundChatThreadListProps = {| navigation: ChatTopTabsNavigationProp<'BackgroundChatThreadList'>, route: NavigationRoute<'BackgroundChatThreadList'>, |}; export default function BackgroundChatThreadList( props: BackgroundChatThreadListProps, ) { const unreadBackgroundThreadsNumber = useSelector((state) => unreadBackgroundCount(state), ); const prevUnreadNumber = React.useRef(0); React.useEffect(() => { if (unreadBackgroundThreadsNumber === prevUnreadNumber.current) { return; } prevUnreadNumber.current = unreadBackgroundThreadsNumber; let title = 'Background'; if (unreadBackgroundThreadsNumber !== 0) { title += ` (${unreadBackgroundThreadsNumber})`; } props.navigation.setOptions({ title }); }, [props.navigation, unreadBackgroundThreadsNumber]); return ( ); } function EmptyItem() { const styles = useStyles(unboundStyles); return {emptyItemText}; } const unboundStyles = { emptyList: { color: 'listBackgroundLabel', fontSize: 17, marginHorizontal: 15, marginVertical: 10, textAlign: 'center', }, }; diff --git a/native/chat/chat-header.react.js b/native/chat/chat-header.react.js index 6cf571e4b..57c346290 100644 --- a/native/chat/chat-header.react.js +++ b/native/chat/chat-header.react.js @@ -1,20 +1,19 @@ // @flow import type { StackHeaderProps } from '@react-navigation/stack'; - import * as React from 'react'; import Header from '../navigation/header.react'; import { createActiveTabSelector } from '../navigation/nav-selectors'; -import { ChatRouteName } from '../navigation/route-names'; import { NavContext } from '../navigation/navigation-context'; +import { ChatRouteName } from '../navigation/route-names'; const activeTabSelector = createActiveTabSelector(ChatRouteName); export default React.memo(function ChatHeader( props: StackHeaderProps, ) { const navContext = React.useContext(NavContext); const activeTab = activeTabSelector(navContext); return
; }); diff --git a/native/chat/chat-input-bar.react.js b/native/chat/chat-input-bar.react.js index 5348e4a45..286e6db01 100644 --- a/native/chat/chat-input-bar.react.js +++ b/native/chat/chat-input-bar.react.js @@ -1,842 +1,841 @@ // @flow +import invariant from 'invariant'; +import { saveDraftActionType } from 'lib/actions/miscellaneous-action-types'; +import { joinThreadActionTypes, joinThread } from 'lib/actions/thread-actions'; +import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; +import { trimMessage } from 'lib/shared/message-utils'; +import { + threadHasPermission, + viewerIsMember, + threadFrozenDueToViewerBlock, + threadActualMembers, +} from 'lib/shared/thread-utils'; +import type { CalendarQuery } from 'lib/types/entry-types'; +import { loadingStatusPropType } from 'lib/types/loading-types'; +import type { LoadingStatus } from 'lib/types/loading-types'; import { messageTypes } from 'lib/types/message-types'; +import type { Dispatch } from 'lib/types/redux-types'; import { type ThreadInfo, threadInfoPropType, threadPermissions, type ClientThreadJoinRequest, type ThreadJoinPayload, } from 'lib/types/thread-types'; -import type { LoadingStatus } from 'lib/types/loading-types'; -import { loadingStatusPropType } from 'lib/types/loading-types'; -import type { CalendarQuery } from 'lib/types/entry-types'; import { type UserInfos, userInfoPropType } from 'lib/types/user-types'; import { - type KeyboardState, - keyboardStatePropType, - KeyboardContext, -} from '../keyboard/keyboard-state'; -import { - messageListRoutePropType, - messageListNavPropType, -} from './message-list-types'; -import { - type InputState, - inputStatePropType, - InputStateContext, -} from '../input/input-state'; -import type { ChatNavigationProp } from './chat.react'; -import type { NavigationRoute } from '../navigation/route-names'; -import { NavContext } from '../navigation/navigation-context'; -import type { Dispatch } from 'lib/types/redux-types'; -import type { ViewStyle } from '../types/styles'; - + type DispatchActionPromise, + useServerCall, + useDispatchActionPromise, +} from 'lib/utils/action-utils'; +import _throttle from 'lodash/throttle'; +import PropTypes from 'prop-types'; import * as React from 'react'; import { View, TextInput, TouchableOpacity, Platform, Text, ActivityIndicator, TouchableWithoutFeedback, } from 'react-native'; -import Icon from 'react-native-vector-icons/Ionicons'; -import FAIcon from 'react-native-vector-icons/FontAwesome'; -import PropTypes from 'prop-types'; -import invariant from 'invariant'; -import Animated, { Easing } from 'react-native-reanimated'; import { TextInputKeyboardMangerIOS } from 'react-native-keyboard-input'; -import _throttle from 'lodash/throttle'; +import Animated, { Easing } from 'react-native-reanimated'; +import FAIcon from 'react-native-vector-icons/FontAwesome'; +import Icon from 'react-native-vector-icons/Ionicons'; import { useDispatch } from 'react-redux'; -import { saveDraftActionType } from 'lib/actions/miscellaneous-action-types'; +import Button from '../components/button.react'; +import ClearableTextInput from '../components/clearable-text-input.react'; import { - threadHasPermission, - viewerIsMember, - threadFrozenDueToViewerBlock, - threadActualMembers, -} from 'lib/shared/thread-utils'; -import { joinThreadActionTypes, joinThread } from 'lib/actions/thread-actions'; -import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; -import { trimMessage } from 'lib/shared/message-utils'; + type InputState, + inputStatePropType, + InputStateContext, +} from '../input/input-state'; +import { getKeyboardHeight } from '../keyboard/keyboard'; +import KeyboardInputHost from '../keyboard/keyboard-input-host.react'; import { - type DispatchActionPromise, - useServerCall, - useDispatchActionPromise, -} from 'lib/utils/action-utils'; - -import Button from '../components/button.react'; + type KeyboardState, + keyboardStatePropType, + KeyboardContext, +} from '../keyboard/keyboard-state'; import { nonThreadCalendarQuery, activeThreadSelector, } from '../navigation/nav-selectors'; -import { getKeyboardHeight } from '../keyboard/keyboard'; +import { NavContext } from '../navigation/navigation-context'; +import type { NavigationRoute } from '../navigation/route-names'; +import { CameraModalRouteName } from '../navigation/route-names'; +import { useSelector } from '../redux/redux-utils'; import { type Colors, colorsPropType, useStyles, useColors, } from '../themes/colors'; -import { CameraModalRouteName } from '../navigation/route-names'; -import KeyboardInputHost from '../keyboard/keyboard-input-host.react'; -import ClearableTextInput from '../components/clearable-text-input.react'; +import type { ViewStyle } from '../types/styles'; import { runTiming } from '../utils/animation-utils'; -import { useSelector } from '../redux/redux-utils'; + +import type { ChatNavigationProp } from './chat.react'; +import { + messageListRoutePropType, + messageListNavPropType, +} from './message-list-types'; /* eslint-disable import/no-named-as-default-member */ const { Value, Clock, block, set, cond, neq, sub, interpolate, stopClock, } = Animated; /* eslint-enable import/no-named-as-default-member */ const expandoButtonsAnimationConfig = { duration: 500, easing: Easing.inOut(Easing.ease), }; const sendButtonAnimationConfig = { duration: 150, easing: Easing.inOut(Easing.ease), }; const draftKeyFromThreadID = (threadID: string) => `${threadID}/message_composer`; type BaseProps = {| +threadInfo: ThreadInfo, +navigation: ChatNavigationProp<'MessageList'>, +route: NavigationRoute<'MessageList'>, |}; type Props = {| ...BaseProps, // Redux state +viewerID: ?string, +draft: string, +joinThreadLoadingStatus: LoadingStatus, +calendarQuery: () => CalendarQuery, +nextLocalID: number, +userInfos: UserInfos, +colors: Colors, +styles: typeof unboundStyles, // connectNav +isActive: boolean, // withKeyboardState +keyboardState: ?KeyboardState, // Redux dispatch functions +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +joinThread: (request: ClientThreadJoinRequest) => Promise, // withInputState +inputState: ?InputState, |}; type State = {| +text: string, +buttonsExpanded: boolean, |}; class ChatInputBar extends React.PureComponent { static propTypes = { threadInfo: threadInfoPropType.isRequired, navigation: messageListNavPropType.isRequired, route: messageListRoutePropType.isRequired, isActive: PropTypes.bool.isRequired, viewerID: PropTypes.string, draft: PropTypes.string.isRequired, joinThreadLoadingStatus: loadingStatusPropType.isRequired, calendarQuery: PropTypes.func.isRequired, nextLocalID: PropTypes.number.isRequired, userInfos: PropTypes.objectOf(userInfoPropType).isRequired, colors: colorsPropType.isRequired, styles: PropTypes.objectOf(PropTypes.object).isRequired, keyboardState: keyboardStatePropType, dispatch: PropTypes.func.isRequired, dispatchActionPromise: PropTypes.func.isRequired, joinThread: PropTypes.func.isRequired, inputState: inputStatePropType, }; textInput: ?React.ElementRef; clearableTextInput: ?ClearableTextInput; expandoButtonsOpen: Value; targetExpandoButtonsOpen: Value; expandoButtonsStyle: ViewStyle; cameraRollIconStyle: ViewStyle; cameraIconStyle: ViewStyle; expandIconStyle: ViewStyle; sendButtonContainerOpen: Value; targetSendButtonContainerOpen: Value; sendButtonContainerStyle: ViewStyle; constructor(props: Props) { super(props); this.state = { text: props.draft, buttonsExpanded: true, }; this.expandoButtonsOpen = new Value(1); this.targetExpandoButtonsOpen = new Value(1); const prevTargetExpandoButtonsOpen = new Value(1); const expandoButtonClock = new Clock(); const expandoButtonsOpen = block([ cond(neq(this.targetExpandoButtonsOpen, prevTargetExpandoButtonsOpen), [ stopClock(expandoButtonClock), set(prevTargetExpandoButtonsOpen, this.targetExpandoButtonsOpen), ]), cond( neq(this.expandoButtonsOpen, this.targetExpandoButtonsOpen), set( this.expandoButtonsOpen, runTiming( expandoButtonClock, this.expandoButtonsOpen, this.targetExpandoButtonsOpen, true, expandoButtonsAnimationConfig, ), ), ), this.expandoButtonsOpen, ]); this.cameraRollIconStyle = { ...unboundStyles.cameraRollIcon, opacity: expandoButtonsOpen, }; this.cameraIconStyle = { ...unboundStyles.cameraIcon, opacity: expandoButtonsOpen, }; const expandoButtonsWidth = interpolate(expandoButtonsOpen, { inputRange: [0, 1], outputRange: [22, 60], }); this.expandoButtonsStyle = { ...unboundStyles.expandoButtons, width: expandoButtonsWidth, }; const expandOpacity = sub(1, expandoButtonsOpen); this.expandIconStyle = { ...unboundStyles.expandIcon, opacity: expandOpacity, }; const initialSendButtonContainerOpen = trimMessage(props.draft) ? 1 : 0; this.sendButtonContainerOpen = new Value(initialSendButtonContainerOpen); this.targetSendButtonContainerOpen = new Value( initialSendButtonContainerOpen, ); const prevTargetSendButtonContainerOpen = new Value( initialSendButtonContainerOpen, ); const sendButtonClock = new Clock(); const sendButtonContainerOpen = block([ cond( neq( this.targetSendButtonContainerOpen, prevTargetSendButtonContainerOpen, ), [ stopClock(sendButtonClock), set( prevTargetSendButtonContainerOpen, this.targetSendButtonContainerOpen, ), ], ), cond( neq(this.sendButtonContainerOpen, this.targetSendButtonContainerOpen), set( this.sendButtonContainerOpen, runTiming( sendButtonClock, this.sendButtonContainerOpen, this.targetSendButtonContainerOpen, true, sendButtonAnimationConfig, ), ), ), this.sendButtonContainerOpen, ]); const sendButtonContainerWidth = interpolate(sendButtonContainerOpen, { inputRange: [0, 1], outputRange: [4, 38], }); this.sendButtonContainerStyle = { width: sendButtonContainerWidth }; } static mediaGalleryOpen(props: Props) { const { keyboardState } = props; return !!(keyboardState && keyboardState.mediaGalleryOpen); } static systemKeyboardShowing(props: Props) { const { keyboardState } = props; return !!(keyboardState && keyboardState.systemKeyboardShowing); } get systemKeyboardShowing() { return ChatInputBar.systemKeyboardShowing(this.props); } immediatelyShowSendButton() { this.sendButtonContainerOpen.setValue(1); this.targetSendButtonContainerOpen.setValue(1); } updateSendButton(currentText: string) { this.targetSendButtonContainerOpen.setValue(currentText === '' ? 0 : 1); } componentDidMount() { if (this.props.isActive) { this.addReplyListener(); } } componentWillUnmount() { if (this.props.isActive) { this.removeReplyListener(); } } componentDidUpdate(prevProps: Props, prevState: State) { if (this.props.isActive && !prevProps.isActive) { this.addReplyListener(); } else if (!this.props.isActive && prevProps.isActive) { this.removeReplyListener(); } const currentText = trimMessage(this.state.text); const prevText = trimMessage(prevState.text); if ( (currentText === '' && prevText !== '') || (currentText !== '' && prevText === '') ) { this.updateSendButton(currentText); } const systemKeyboardIsShowing = ChatInputBar.systemKeyboardShowing( this.props, ); const systemKeyboardWasShowing = ChatInputBar.systemKeyboardShowing( prevProps, ); if (systemKeyboardIsShowing && !systemKeyboardWasShowing) { this.hideButtons(); } else if (!systemKeyboardIsShowing && systemKeyboardWasShowing) { this.expandButtons(); } const imageGalleryIsOpen = ChatInputBar.mediaGalleryOpen(this.props); const imageGalleryWasOpen = ChatInputBar.mediaGalleryOpen(prevProps); if (!imageGalleryIsOpen && imageGalleryWasOpen) { this.hideButtons(); } else if (imageGalleryIsOpen && !imageGalleryWasOpen) { this.expandButtons(); this.setIOSKeyboardHeight(); } } addReplyListener() { invariant( this.props.inputState, 'inputState should be set in addReplyListener', ); this.props.inputState.addReplyListener(this.focusAndUpdateText); } removeReplyListener() { invariant( this.props.inputState, 'inputState should be set in removeReplyListener', ); this.props.inputState.removeReplyListener(this.focusAndUpdateText); } setIOSKeyboardHeight() { if (Platform.OS !== 'ios') { return; } const { textInput } = this; if (!textInput) { return; } const keyboardHeight = getKeyboardHeight(); if (keyboardHeight === null || keyboardHeight === undefined) { return; } TextInputKeyboardMangerIOS.setKeyboardHeight(textInput, keyboardHeight); } render() { const isMember = viewerIsMember(this.props.threadInfo); const canJoin = threadHasPermission( this.props.threadInfo, threadPermissions.JOIN_THREAD, ); let joinButton = null; if (!isMember && canJoin) { let buttonContent; if (this.props.joinThreadLoadingStatus === 'loading') { buttonContent = ( ); } else { buttonContent = ( Join Thread ); } joinButton = ( ); } let content; if (threadHasPermission(this.props.threadInfo, threadPermissions.VOICED)) { content = this.renderInput(); } else if ( threadFrozenDueToViewerBlock( this.props.threadInfo, this.props.viewerID, this.props.userInfos, ) && threadActualMembers(this.props.threadInfo.members).length === 2 ) { content = ( You can't send messages to a user that you've blocked. ); } else if (isMember) { content = ( You don't have permission to send messages. ); } else { const defaultRoleID = Object.keys(this.props.threadInfo.roles).find( (roleID) => this.props.threadInfo.roles[roleID].isDefault, ); invariant( defaultRoleID !== undefined, 'all threads should have a default role', ); const defaultRole = this.props.threadInfo.roles[defaultRoleID]; const membersAreVoiced = !!defaultRole.permissions[ threadPermissions.VOICED ]; if (membersAreVoiced && canJoin) { content = ( Join this thread to send messages. ); } else { content = ( You don't have permission to send messages. ); } } const keyboardInputHost = Platform.OS === 'android' ? null : ( ); return ( {joinButton} {content} {keyboardInputHost} ); } renderInput() { const expandoButton = ( ); return ( {this.state.buttonsExpanded ? expandoButton : null} {this.state.buttonsExpanded ? null : expandoButton} ); } textInputRef = (textInput: ?React.ElementRef) => { this.textInput = textInput; }; clearableTextInputRef = (clearableTextInput: ?ClearableTextInput) => { this.clearableTextInput = clearableTextInput; }; updateText = (text: string) => { this.setState({ text }); this.saveDraft(text); }; saveDraft = _throttle((text: string) => { this.props.dispatch({ type: saveDraftActionType, payload: { key: draftKeyFromThreadID(this.props.threadInfo.id), draft: text, }, }); }, 400); focusAndUpdateText = (text: string) => { const currentText = this.state.text; if (!currentText.startsWith(text)) { const prependedText = text.concat(currentText); this.updateText(prependedText); this.immediatelyShowSendButton(); this.immediatelyHideButtons(); } invariant(this.textInput, 'textInput should be set in focusAndUpdateText'); this.textInput.focus(); }; onSend = async () => { if (!trimMessage(this.state.text)) { return; } this.updateSendButton(''); const { clearableTextInput } = this; invariant( clearableTextInput, 'clearableTextInput should be sent in onSend', ); let text = await clearableTextInput.getValueAndReset(); text = trimMessage(text); if (!text) { return; } const localID = `local${this.props.nextLocalID}`; const creatorID = this.props.viewerID; invariant(creatorID, 'should have viewer ID in order to send a message'); invariant( this.props.inputState, 'inputState should be set in ChatInputBar.onSend', ); this.props.inputState.sendTextMessage({ type: messageTypes.TEXT, localID, threadID: this.props.threadInfo.id, text, creatorID, time: Date.now(), }); }; onPressJoin = () => { this.props.dispatchActionPromise(joinThreadActionTypes, this.joinAction()); }; async joinAction() { const query = this.props.calendarQuery(); return await this.props.joinThread({ threadID: this.props.threadInfo.id, calendarQuery: { startDate: query.startDate, endDate: query.endDate, filters: [ ...query.filters, { type: 'threads', threadIDs: [this.props.threadInfo.id] }, ], }, }); } expandButtons = () => { if (this.state.buttonsExpanded) { return; } this.targetExpandoButtonsOpen.setValue(1); this.setState({ buttonsExpanded: true }); }; hideButtons() { if ( ChatInputBar.mediaGalleryOpen(this.props) || !this.systemKeyboardShowing || !this.state.buttonsExpanded ) { return; } this.targetExpandoButtonsOpen.setValue(0); this.setState({ buttonsExpanded: false }); } immediatelyHideButtons() { this.expandoButtonsOpen.setValue(0); this.targetExpandoButtonsOpen.setValue(0); this.setState({ buttonsExpanded: false }); } openCamera = async () => { this.dismissKeyboard(); this.props.navigation.navigate({ name: CameraModalRouteName, params: { presentedFrom: this.props.route.key, threadID: this.props.threadInfo.id, }, }); }; showMediaGallery = () => { const { keyboardState } = this.props; invariant(keyboardState, 'keyboardState should be initialized'); keyboardState.showMediaGallery(this.props.threadInfo.id); }; dismissKeyboard = () => { const { keyboardState } = this.props; keyboardState && keyboardState.dismissKeyboard(); }; } const unboundStyles = { cameraIcon: { paddingBottom: Platform.OS === 'android' ? 11 : 10, paddingRight: 3, }, cameraRollIcon: { paddingBottom: Platform.OS === 'android' ? 8 : 7, paddingRight: 8, }, container: { backgroundColor: 'listBackground', }, expandButton: { bottom: 0, position: 'absolute', right: 0, }, expandIcon: { paddingBottom: Platform.OS === 'android' ? 12 : 10, }, expandoButtons: { alignSelf: 'flex-end', }, explanation: { color: 'listBackgroundSecondaryLabel', paddingBottom: 4, paddingTop: 1, textAlign: 'center', }, innerExpandoButtons: { alignItems: 'flex-end', alignSelf: 'flex-end', flexDirection: 'row', }, inputContainer: { flexDirection: 'row', }, joinButton: { backgroundColor: 'mintButton', borderRadius: 5, flex: 1, justifyContent: 'center', marginHorizontal: 12, marginVertical: 3, paddingBottom: 5, paddingTop: 3, }, joinButtonContainer: { flexDirection: 'row', height: 36, }, joinButtonText: { color: 'listBackground', fontSize: 20, textAlign: 'center', }, joinThreadLoadingIndicator: { paddingVertical: 2, }, sendButton: { position: 'absolute', bottom: Platform.OS === 'android' ? 4 : 3, left: 0, }, sendIcon: { paddingLeft: 9, paddingRight: 8, paddingVertical: 5, }, textInput: { backgroundColor: 'listInputBackground', borderRadius: 10, color: 'listForegroundLabel', fontSize: 16, marginLeft: 4, marginVertical: 5, maxHeight: 250, paddingHorizontal: 10, paddingVertical: 5, }, }; const joinThreadLoadingStatusSelector = createLoadingStatusSelector( joinThreadActionTypes, ); export default React.memo(function ConnectedChatInputBar( props: BaseProps, ) { const inputState = React.useContext(InputStateContext); const keyboardState = React.useContext(KeyboardContext); const navContext = React.useContext(NavContext); const styles = useStyles(unboundStyles); const colors = useColors(); const isActive = React.useMemo( () => props.threadInfo.id === activeThreadSelector(navContext), [props.threadInfo.id, navContext], ); const draftKey = draftKeyFromThreadID(props.threadInfo.id); const draft = useSelector((state) => state.drafts[draftKey] || ''); const viewerID = useSelector( (state) => state.currentUserInfo && state.currentUserInfo.id, ); const joinThreadLoadingStatus = useSelector(joinThreadLoadingStatusSelector); const calendarQuery = useSelector((state) => nonThreadCalendarQuery({ redux: state, navContext, }), ); const nextLocalID = useSelector((state) => state.nextLocalID); const userInfos = useSelector((state) => state.userStore.userInfos); const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const callJoinThread = useServerCall(joinThread); return ( ); }); diff --git a/native/chat/chat-list.react.js b/native/chat/chat-list.react.js index c2862cf94..2485a7664 100644 --- a/native/chat/chat-list.react.js +++ b/native/chat/chat-list.react.js @@ -1,323 +1,322 @@ // @flow -import type { - Props as FlatListProps, - DefaultProps as FlatListDefaultProps, -} from 'react-native/Libraries/Lists/FlatList'; -import type { ChatNavigationProp } from './chat.react'; -import type { TabNavigationProp } from '../navigation/app-navigator.react'; -import type { ChatMessageItemWithHeight } from './message-list-container.react'; -import type { ViewStyle } from '../types/styles'; -import { - type KeyboardState, - KeyboardContext, -} from '../keyboard/keyboard-state'; +import invariant from 'invariant'; import type { ChatMessageItem } from 'lib/selectors/chat-selectors'; - +import { messageKey } from 'lib/shared/message-utils'; +import _sum from 'lodash/fp/sum'; import * as React from 'react'; import { FlatList, Animated, Easing, StyleSheet, TouchableWithoutFeedback, View, } from 'react-native'; -import invariant from 'invariant'; -import _sum from 'lodash/fp/sum'; +import type { + Props as FlatListProps, + DefaultProps as FlatListDefaultProps, +} from 'react-native/Libraries/Lists/FlatList'; -import { messageKey } from 'lib/shared/message-utils'; +import { + type KeyboardState, + KeyboardContext, +} from '../keyboard/keyboard-state'; +import type { TabNavigationProp } from '../navigation/app-navigator.react'; +import { useSelector } from '../redux/redux-utils'; +import type { ViewStyle } from '../types/styles'; +import type { ChatNavigationProp } from './chat.react'; +import type { ChatMessageItemWithHeight } from './message-list-container.react'; import { messageItemHeight } from './message.react'; import NewMessagesPill from './new-messages-pill.react'; -import { useSelector } from '../redux/redux-utils'; function chatMessageItemKey(item: ChatMessageItemWithHeight | ChatMessageItem) { if (item.itemType === 'loader') { return 'loader'; } return messageKey(item.messageInfo); } function chatMessageItemHeight(item: ChatMessageItemWithHeight) { if (item.itemType === 'loader') { return 56; } return messageItemHeight(item); } const animationSpec = { duration: 150, useNativeDriver: true, }; type BaseProps = {| ...$ReadOnly< $Exact< React.Config< FlatListProps, FlatListDefaultProps, >, >, >, +navigation: ChatNavigationProp<'MessageList'>, +data: $ReadOnlyArray, |}; type Props = {| ...BaseProps, // Redux state +viewerID: ?string, // withKeyboardState +keyboardState: ?KeyboardState, |}; type State = {| +newMessageCount: number, |}; class ChatList extends React.PureComponent { state: State = { newMessageCount: 0, }; flatList: ?React.ElementRef; scrollPos = 0; newMessagesPillProgress = new Animated.Value(0); newMessagesPillStyle: ViewStyle; constructor(props: Props) { super(props); const sendButtonTranslateY = this.newMessagesPillProgress.interpolate({ inputRange: [0, 1], outputRange: ([10, 0]: number[]), // Flow... }); this.newMessagesPillStyle = { opacity: this.newMessagesPillProgress, transform: [{ translateY: sendButtonTranslateY }], }; } componentDidMount() { const tabNavigation: ?TabNavigationProp< 'Chat', > = this.props.navigation.dangerouslyGetParent(); invariant(tabNavigation, 'ChatNavigator should be within TabNavigator'); tabNavigation.addListener('tabPress', this.onTabPress); } componentWillUnmount() { const tabNavigation: ?TabNavigationProp< 'Chat', > = this.props.navigation.dangerouslyGetParent(); invariant(tabNavigation, 'ChatNavigator should be within TabNavigator'); tabNavigation.removeListener('tabPress', this.onTabPress); } onTabPress = () => { const { flatList } = this; if (!this.props.navigation.isFocused() || !flatList) { return; } if (this.scrollPos > 0) { flatList.scrollToOffset({ offset: 0 }); } else { this.props.navigation.popToTop(); } }; get scrolledToBottom() { return this.scrollPos <= 0; } componentDidUpdate(prevProps: Props) { const { flatList } = this; if (!flatList || this.props.data.length === prevProps.data.length) { return; } if (this.props.data.length < prevProps.data.length) { // This should only happen due to MessageStorePruner, // which will only prune a thread when it is off-screen flatList.scrollToOffset({ offset: 0, animated: false }); return; } const { scrollPos } = this; let curDataIndex = 0, prevDataIndex = 0, heightSoFar = 0; let adjustScrollPos = 0, newLocalMessage = false, newRemoteMessageCount = 0; while (prevDataIndex < prevProps.data.length && heightSoFar <= scrollPos) { const prevItem = prevProps.data[prevDataIndex]; invariant(prevItem, 'prevDatum should exist'); const prevItemKey = chatMessageItemKey(prevItem); const prevItemHeight = chatMessageItemHeight(prevItem); let curItem = this.props.data[curDataIndex]; while (curItem) { const curItemKey = chatMessageItemKey(curItem); if (curItemKey === prevItemKey) { break; } if (curItemKey.startsWith('local')) { newLocalMessage = true; } else if ( curItem.itemType === 'message' && curItem.messageInfo.creator.id !== this.props.viewerID ) { newRemoteMessageCount++; } adjustScrollPos += chatMessageItemHeight(curItem); curDataIndex++; curItem = this.props.data[curDataIndex]; } if (!curItem) { // Should never happen... console.log(`items added to ChatList, but ${prevItemKey} now missing`); return; } const curItemHeight = chatMessageItemHeight(curItem); adjustScrollPos += curItemHeight - prevItemHeight; heightSoFar += prevItemHeight; prevDataIndex++; curDataIndex++; } if (adjustScrollPos === 0) { return; } flatList.scrollToOffset({ offset: scrollPos + adjustScrollPos, animated: false, }); if (newLocalMessage || scrollPos <= 0) { flatList.scrollToOffset({ offset: 0 }); } else if (newRemoteMessageCount > 0) { this.setState((prevState) => ({ newMessageCount: prevState.newMessageCount + newRemoteMessageCount, })); this.toggleNewMessagesPill(true); } } render() { const { navigation, viewerID, ...rest } = this.props; const { newMessageCount } = this.state; return ( 0 ? 'auto' : 'none'} containerStyle={styles.newMessagesPillContainer} style={this.newMessagesPillStyle} /> ); } flatListRef = (flatList: ?React.ElementRef) => { this.flatList = flatList; }; static getItemLayout( data: ?$ReadOnlyArray, index: number, ) { if (!data) { return { length: 0, offset: 0, index }; } const offset = ChatList.heightOfItems(data.filter((_, i) => i < index)); const item = data[index]; const length = item ? chatMessageItemHeight(item) : 0; return { length, offset, index }; } static heightOfItems( data: $ReadOnlyArray, ): number { return _sum(data.map(chatMessageItemHeight)); } toggleNewMessagesPill(show: boolean) { Animated.timing(this.newMessagesPillProgress, { ...animationSpec, easing: show ? Easing.ease : Easing.out(Easing.ease), toValue: show ? 1 : 0, }).start(({ finished }) => { if (finished && !show) { this.setState({ newMessageCount: 0 }); } }); } onScroll = (event: { +nativeEvent: { +contentOffset: { +y: number }, +contentSize: { +height: number }, }, }) => { this.scrollPos = event.nativeEvent.contentOffset.y; if (this.scrollPos <= 0) { this.toggleNewMessagesPill(false); } // $FlowFixMe FlatList doesn't type ScrollView props this.props.onScroll && this.props.onScroll(event); }; onPressNewMessagesPill = () => { const { flatList } = this; if (!flatList) { return; } flatList.scrollToOffset({ offset: 0 }); this.toggleNewMessagesPill(false); }; onPressBackground = () => { const { keyboardState } = this.props; keyboardState && keyboardState.dismissKeyboard(); }; } const styles = StyleSheet.create({ container: { flex: 1, }, newMessagesPillContainer: { bottom: 30, position: 'absolute', right: 30, }, }); const ConnectedChatList = React.memo(function ConnectedChatList( props: BaseProps, ) { const keyboardState = React.useContext(KeyboardContext); const viewerID = useSelector( (state) => state.currentUserInfo && state.currentUserInfo.id, ); return ( ); }); export { ConnectedChatList as ChatList, chatMessageItemKey }; diff --git a/native/chat/chat-router.js b/native/chat/chat-router.js index a6fcbde75..f4d48ed5d 100644 --- a/native/chat/chat-router.js +++ b/native/chat/chat-router.js @@ -1,179 +1,178 @@ // @flow -import type { ThreadInfo } from 'lib/types/thread-types'; import type { StackNavigationProp, ParamListBase, StackAction, Route, Router, StackRouterOptions, StackNavigationState, RouterConfigOptions, GenericNavigationAction, } from '@react-navigation/native'; - import { StackRouter, CommonActions } from '@react-navigation/native'; +import type { ThreadInfo } from 'lib/types/thread-types'; -import { - ChatThreadListRouteName, - MessageListRouteName, - ComposeThreadRouteName, -} from '../navigation/route-names'; -import { - removeScreensFromStack, - getThreadIDFromRoute, -} from '../navigation/navigation-utils'; import { clearScreensActionType, replaceWithThreadActionType, clearThreadsActionType, pushNewThreadActionType, } from '../navigation/action-types'; +import { + removeScreensFromStack, + getThreadIDFromRoute, +} from '../navigation/navigation-utils'; +import { + ChatThreadListRouteName, + MessageListRouteName, + ComposeThreadRouteName, +} from '../navigation/route-names'; type ClearScreensAction = {| +type: 'CLEAR_SCREENS', +payload: {| +routeNames: $ReadOnlyArray, |}, |}; type ReplaceWithThreadAction = {| +type: 'REPLACE_WITH_THREAD', +payload: {| +threadInfo: ThreadInfo, |}, |}; type ClearThreadsAction = {| +type: 'CLEAR_THREADS', +payload: {| +threadIDs: $ReadOnlyArray, |}, |}; type PushNewThreadAction = {| +type: 'PUSH_NEW_THREAD', +payload: {| +threadInfo: ThreadInfo, |}, |}; export type ChatRouterNavigationAction = | StackAction | ClearScreensAction | ReplaceWithThreadAction | ClearThreadsAction | PushNewThreadAction; export type ChatRouterNavigationProp< ParamList: ParamListBase = ParamListBase, RouteName: string = string, > = {| ...StackNavigationProp, +clearScreens: (routeNames: $ReadOnlyArray) => void, +replaceWithThread: (threadInfo: ThreadInfo) => void, +clearThreads: (threadIDs: $ReadOnlyArray) => void, +pushNewThread: (threadInfo: ThreadInfo) => void, |}; function ChatRouter( routerOptions: StackRouterOptions, ): Router { const { getStateForAction: baseGetStateForAction, actionCreators: baseActionCreators, shouldActionChangeFocus: baseShouldActionChangeFocus, ...rest } = StackRouter(routerOptions); return { ...rest, getStateForAction: ( lastState: StackNavigationState, action: ChatRouterNavigationAction, options: RouterConfigOptions, ) => { if (action.type === clearScreensActionType) { const { routeNames } = action.payload; if (!lastState) { return lastState; } return removeScreensFromStack(lastState, (route: Route<>) => routeNames.includes(route.name) ? 'remove' : 'keep', ); } else if (action.type === replaceWithThreadActionType) { const { threadInfo } = action.payload; if (!lastState) { return lastState; } const clearedState = removeScreensFromStack( lastState, (route: Route<>) => route.name === ChatThreadListRouteName ? 'keep' : 'remove', ); const navigateAction = CommonActions.navigate({ name: MessageListRouteName, key: `${MessageListRouteName}${threadInfo.id}`, params: { threadInfo }, }); return baseGetStateForAction(clearedState, navigateAction, options); } else if (action.type === clearThreadsActionType) { const threadIDs = new Set(action.payload.threadIDs); if (!lastState) { return lastState; } return removeScreensFromStack(lastState, (route: Route<>) => threadIDs.has(getThreadIDFromRoute(route)) ? 'remove' : 'keep', ); } else if (action.type === pushNewThreadActionType) { const { threadInfo } = action.payload; if (!lastState) { return lastState; } const clearedState = removeScreensFromStack( lastState, (route: Route<>) => route.name === ComposeThreadRouteName ? 'remove' : 'break', ); const navigateAction = CommonActions.navigate({ name: MessageListRouteName, key: `${MessageListRouteName}${threadInfo.id}`, params: { threadInfo }, }); return baseGetStateForAction(clearedState, navigateAction, options); } else { return baseGetStateForAction(lastState, action, options); } }, actionCreators: { ...baseActionCreators, clearScreens: (routeNames: $ReadOnlyArray) => ({ type: clearScreensActionType, payload: { routeNames, }, }), replaceWithThread: (threadInfo: ThreadInfo) => ({ type: replaceWithThreadActionType, payload: { threadInfo }, }: ReplaceWithThreadAction), clearThreads: (threadIDs: $ReadOnlyArray) => ({ type: clearThreadsActionType, payload: { threadIDs }, }), pushNewThread: (threadInfo: ThreadInfo) => ({ type: pushNewThreadActionType, payload: { threadInfo }, }: PushNewThreadAction), }, shouldActionChangeFocus: (action: GenericNavigationAction) => { if (action.type === replaceWithThreadActionType) { return true; } else if (action.type === pushNewThreadActionType) { return true; } else { return baseShouldActionChangeFocus(action); } }, }; } export default ChatRouter; diff --git a/native/chat/chat-thread-list-item.react.js b/native/chat/chat-thread-list-item.react.js index 277935432..6449104d3 100644 --- a/native/chat/chat-thread-list-item.react.js +++ b/native/chat/chat-thread-list-item.react.js @@ -1,164 +1,163 @@ // @flow import type { ChatThreadItem } from 'lib/selectors/chat-selectors'; import type { ThreadInfo } from 'lib/types/thread-types'; - +import { shortAbsoluteDate } from 'lib/utils/date-utils'; import * as React from 'react'; import { Text, View } from 'react-native'; -import { shortAbsoluteDate } from 'lib/utils/date-utils'; - import Button from '../components/button.react'; -import MessagePreview from './message-preview.react'; import ColorSplotch from '../components/color-splotch.react'; -import { useColors, useStyles } from '../themes/colors'; import { SingleLine } from '../components/single-line.react'; -import ChatThreadListSidebar from './chat-thread-list-sidebar.react'; +import { useColors, useStyles } from '../themes/colors'; + import ChatThreadListSeeMoreSidebars from './chat-thread-list-see-more-sidebars.react'; +import ChatThreadListSidebar from './chat-thread-list-sidebar.react'; +import MessagePreview from './message-preview.react'; import SwipeableThread from './swipeable-thread.react'; type Props = {| +data: ChatThreadItem, +onPressItem: (threadInfo: ThreadInfo) => void, +onPressSeeMoreSidebars: (threadInfo: ThreadInfo) => void, +onSwipeableWillOpen: (threadInfo: ThreadInfo) => void, +currentlyOpenedSwipeableId: string, |}; function ChatThreadListItem({ data, onPressItem, onPressSeeMoreSidebars, onSwipeableWillOpen, currentlyOpenedSwipeableId, }: Props) { const styles = useStyles(unboundStyles); const colors = useColors(); const lastMessage = React.useMemo(() => { const mostRecentMessageInfo = data.mostRecentMessageInfo; if (!mostRecentMessageInfo) { return ( No messages ); } return ( ); }, [data.mostRecentMessageInfo, data.threadInfo, styles]); const sidebars = data.sidebars.map((sidebarItem) => { if (sidebarItem.type === 'sidebar') { const { type, ...sidebarInfo } = sidebarItem; return ( ); } else { return ( ); } }); const onPress = React.useCallback(() => { onPressItem(data.threadInfo); }, [onPressItem, data.threadInfo]); const lastActivity = shortAbsoluteDate(data.lastUpdatedTime); const unreadStyle = data.threadInfo.currentUser.unread ? styles.unread : null; return ( <> {sidebars} ); } const unboundStyles = { colorSplotch: { marginLeft: 10, marginTop: 2, }, container: { height: 60, paddingLeft: 10, paddingRight: 10, paddingTop: 5, backgroundColor: 'listBackground', }, lastActivity: { color: 'listForegroundTertiaryLabel', fontSize: 16, marginLeft: 10, }, noMessages: { color: 'listForegroundTertiaryLabel', flex: 1, fontSize: 16, fontStyle: 'italic', paddingLeft: 10, }, row: { flex: 1, flexDirection: 'row', justifyContent: 'space-between', }, threadName: { color: 'listForegroundSecondaryLabel', flex: 1, fontSize: 20, paddingLeft: 10, }, unread: { color: 'listForegroundLabel', fontWeight: 'bold', }, }; export default ChatThreadListItem; diff --git a/native/chat/chat-thread-list-see-more-sidebars.react.js b/native/chat/chat-thread-list-see-more-sidebars.react.js index fcaaee79a..2d8c63dd2 100644 --- a/native/chat/chat-thread-list-see-more-sidebars.react.js +++ b/native/chat/chat-thread-list-see-more-sidebars.react.js @@ -1,68 +1,67 @@ // @flow import type { ThreadInfo } from 'lib/types/thread-types'; - import * as React from 'react'; import { Text } from 'react-native'; import Icon from 'react-native-vector-icons/Ionicons'; import Button from '../components/button.react'; import { useColors, useStyles } from '../themes/colors'; type Props = {| +threadInfo: ThreadInfo, +unread: boolean, +onPress: (threadInfo: ThreadInfo) => void, |}; function ChatThreadListSeeMoreSidebars(props: Props) { const { onPress, threadInfo } = props; const onPressButton = React.useCallback(() => onPress(threadInfo), [ onPress, threadInfo, ]); const colors = useColors(); const styles = useStyles(unboundStyles); const unreadStyle = props.unread ? styles.unread : null; return ( ); } const unboundStyles = { unread: { color: 'listForegroundLabel', fontWeight: 'bold', }, button: { height: 30, flexDirection: 'row', display: 'flex', marginLeft: 25, marginRight: 10, alignItems: 'center', }, icon: { paddingLeft: 5, color: 'listForegroundSecondaryLabel', width: 35, }, text: { color: 'listForegroundSecondaryLabel', flex: 1, fontSize: 16, paddingLeft: 5, paddingBottom: 2, }, }; export default ChatThreadListSeeMoreSidebars; diff --git a/native/chat/chat-thread-list-sidebar.react.js b/native/chat/chat-thread-list-sidebar.react.js index b9f3fb220..4a10f3818 100644 --- a/native/chat/chat-thread-list-sidebar.react.js +++ b/native/chat/chat-thread-list-sidebar.react.js @@ -1,36 +1,35 @@ // @flow import type { ThreadInfo, SidebarInfo } from 'lib/types/thread-types'; - import * as React from 'react'; -import SwipeableThread from './swipeable-thread.react'; import SidebarItem from './sidebar-item.react'; +import SwipeableThread from './swipeable-thread.react'; type Props = {| +sidebarInfo: SidebarInfo, +onPressItem: (threadInfo: ThreadInfo) => void, +onSwipeableWillOpen: (threadInfo: ThreadInfo) => void, +currentlyOpenedSwipeableId: string, |}; function ChatThreadListSidebar(props: Props) { const { sidebarInfo, onSwipeableWillOpen, currentlyOpenedSwipeableId, onPressItem, } = props; return ( ); } export default ChatThreadListSidebar; diff --git a/native/chat/chat-thread-list.react.js b/native/chat/chat-thread-list.react.js index b36c54e3b..e248773a4 100644 --- a/native/chat/chat-thread-list.react.js +++ b/native/chat/chat-thread-list.react.js @@ -1,358 +1,357 @@ // @flow -import type { ThreadInfo } from 'lib/types/thread-types'; -import type { TabNavigationProp } from '../navigation/app-navigator.react'; -import type { - ChatTopTabsNavigationProp, - ChatNavigationProp, -} from './chat.react'; - -import * as React from 'react'; -import { View, FlatList, Platform, TextInput } from 'react-native'; -import IonIcon from 'react-native-vector-icons/Ionicons'; -import _sum from 'lodash/fp/sum'; -import { FloatingAction } from 'react-native-floating-action'; -import { createSelector } from 'reselect'; import invariant from 'invariant'; - -import { threadSearchIndex as threadSearchIndexSelector } from 'lib/selectors/nav-selectors'; -import SearchIndex from 'lib/shared/search-index'; import { type ChatThreadItem, chatListData, } from 'lib/selectors/chat-selectors'; +import { threadSearchIndex as threadSearchIndexSelector } from 'lib/selectors/nav-selectors'; +import SearchIndex from 'lib/shared/search-index'; +import type { ThreadInfo } from 'lib/types/thread-types'; +import _sum from 'lodash/fp/sum'; +import * as React from 'react'; +import { View, FlatList, Platform, TextInput } from 'react-native'; +import { FloatingAction } from 'react-native-floating-action'; +import IonIcon from 'react-native-vector-icons/Ionicons'; +import { createSelector } from 'reselect'; -import ChatThreadListItem from './chat-thread-list-item.react'; +import Search from '../components/search.react'; +import type { TabNavigationProp } from '../navigation/app-navigator.react'; import { ComposeThreadRouteName, MessageListRouteName, SidebarListModalRouteName, HomeChatThreadListRouteName, BackgroundChatThreadListRouteName, type NavigationRoute, } from '../navigation/route-names'; +import { useSelector } from '../redux/redux-utils'; import { type IndicatorStyle, indicatorStyleSelector, useStyles, } from '../themes/colors'; -import Search from '../components/search.react'; -import { useSelector } from '../redux/redux-utils'; + +import ChatThreadListItem from './chat-thread-list-item.react'; +import type { + ChatTopTabsNavigationProp, + ChatNavigationProp, +} from './chat.react'; const floatingActions = [ { text: 'Compose', icon: , name: 'compose', position: 1, }, ]; type Item = | ChatThreadItem | {| type: 'search', searchText: string |} | {| type: 'empty', emptyItem: React.ComponentType<{||}> |}; type BaseProps = {| +navigation: | ChatTopTabsNavigationProp<'HomeChatThreadList'> | ChatTopTabsNavigationProp<'BackgroundChatThreadList'>, +route: | NavigationRoute<'HomeChatThreadList'> | NavigationRoute<'BackgroundChatThreadList'>, +filterThreads: (threadItem: ThreadInfo) => boolean, +emptyItem?: React.ComponentType<{||}>, |}; type Props = {| ...BaseProps, // Redux state +chatListData: $ReadOnlyArray, +viewerID: ?string, +threadSearchIndex: SearchIndex, +styles: typeof unboundStyles, +indicatorStyle: IndicatorStyle, |}; type State = {| +searchText: string, +searchResults: Set, +openedSwipeableId: string, |}; type PropsAndState = {| ...Props, ...State |}; class ChatThreadList extends React.PureComponent { state: State = { searchText: '', searchResults: new Set(), openedSwipeableId: '', }; searchInput: ?React.ElementRef; flatList: ?FlatList; scrollPos = 0; componentDidMount() { const chatNavigation: ?ChatNavigationProp< 'ChatThreadList', > = this.props.navigation.dangerouslyGetParent(); invariant(chatNavigation, 'ChatNavigator should be within TabNavigator'); const tabNavigation: ?TabNavigationProp< 'Chat', > = chatNavigation.dangerouslyGetParent(); invariant(tabNavigation, 'ChatNavigator should be within TabNavigator'); tabNavigation.addListener('tabPress', this.onTabPress); } componentWillUnmount() { const chatNavigation: ?ChatNavigationProp< 'ChatThreadList', > = this.props.navigation.dangerouslyGetParent(); invariant(chatNavigation, 'ChatNavigator should be within TabNavigator'); const tabNavigation: ?TabNavigationProp< 'Chat', > = chatNavigation.dangerouslyGetParent(); invariant(tabNavigation, 'ChatNavigator should be within TabNavigator'); tabNavigation.removeListener('tabPress', this.onTabPress); } onTabPress = () => { if (!this.props.navigation.isFocused()) { return; } if (this.scrollPos > 0 && this.flatList) { this.flatList.scrollToOffset({ offset: 0, animated: true }); } else if (this.props.route.name === BackgroundChatThreadListRouteName) { this.props.navigation.navigate({ name: HomeChatThreadListRouteName }); } }; renderItem = (row: { item: Item }) => { const item = row.item; if (item.type === 'search') { return ( ); } if (item.type === 'empty') { const EmptyItem = item.emptyItem; return ; } return ( ); }; searchInputRef = (searchInput: ?React.ElementRef) => { this.searchInput = searchInput; }; static keyExtractor(item: Item) { if (item.threadInfo) { return item.threadInfo.id; } else if (item.emptyItem) { return 'empty'; } else { return 'search'; } } static getItemLayout(data: ?$ReadOnlyArray, index: number) { if (!data) { return { length: 0, offset: 0, index }; } const offset = ChatThreadList.heightOfItems( data.filter((_, i) => i < index), ); const item = data[index]; const length = item ? ChatThreadList.itemHeight(item) : 0; return { length, offset, index }; } static itemHeight(item: Item): number { if (item.type === 'search') { return Platform.OS === 'ios' ? 54.5 : 55; } // itemHeight for emptyItem might be wrong because of line wrapping // but we don't care because we'll only ever be rendering this item by itself // and it should always be on-screen if (item.type === 'empty') { return 123; } return 60 + item.sidebars.length * 30; } static heightOfItems(data: $ReadOnlyArray): number { return _sum(data.map(ChatThreadList.itemHeight)); } listDataSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.chatListData, (propsAndState: PropsAndState) => propsAndState.searchText, (propsAndState: PropsAndState) => propsAndState.searchResults, (propsAndState: PropsAndState) => propsAndState.emptyItem, ( reduxChatListData: $ReadOnlyArray, searchText: string, searchResults: Set, emptyItem?: React.ComponentType<{||}>, ): Item[] => { const chatItems = []; if (!searchText) { chatItems.push( ...reduxChatListData.filter((item) => this.props.filterThreads(item.threadInfo), ), ); } else { chatItems.push( ...reduxChatListData.filter((item) => searchResults.has(item.threadInfo.id), ), ); } if (emptyItem && chatItems.length === 0) { chatItems.push({ type: 'empty', emptyItem }); } return [{ type: 'search', searchText }, ...chatItems]; }, ); get listData() { return this.listDataSelector({ ...this.props, ...this.state }); } render() { let floatingAction = null; if (Platform.OS === 'android') { floatingAction = ( ); } // this.props.viewerID is in extraData since it's used by MessagePreview // within ChatThreadListItem return ( {floatingAction} ); } flatListRef = (flatList: ?FlatList) => { this.flatList = flatList; }; onScroll = (event: { +nativeEvent: { +contentOffset: { +y: number } } }) => { this.scrollPos = event.nativeEvent.contentOffset.y; }; onChangeSearchText = (searchText: string) => { const results = this.props.threadSearchIndex.getSearchResults(searchText); this.setState({ searchText, searchResults: new Set(results) }); }; onPressItem = (threadInfo: ThreadInfo) => { this.onChangeSearchText(''); if (this.searchInput) { this.searchInput.blur(); } this.props.navigation.navigate({ name: MessageListRouteName, params: { threadInfo }, key: `${MessageListRouteName}${threadInfo.id}`, }); }; onPressSeeMoreSidebars = (threadInfo: ThreadInfo) => { this.onChangeSearchText(''); if (this.searchInput) { this.searchInput.blur(); } this.props.navigation.navigate({ name: SidebarListModalRouteName, params: { threadInfo }, }); }; onSwipeableWillOpen = (threadInfo: ThreadInfo) => { this.setState((state) => ({ ...state, openedSwipeableId: threadInfo.id })); }; composeThread = () => { this.props.navigation.navigate({ name: ComposeThreadRouteName, params: {}, }); }; } const unboundStyles = { icon: { fontSize: 28, }, container: { flex: 1, }, search: { marginBottom: 8, marginHorizontal: 12, marginTop: Platform.OS === 'android' ? 10 : 8, }, flatList: { flex: 1, backgroundColor: 'listBackground', }, }; export default React.memo(function ConnectedChatThreadList( props: BaseProps, ) { const boundChatListData = useSelector(chatListData); const viewerID = useSelector( (state) => state.currentUserInfo && state.currentUserInfo.id, ); const threadSearchIndex = useSelector(threadSearchIndexSelector); const styles = useStyles(unboundStyles); const indicatorStyle = useSelector(indicatorStyleSelector); return ( ); }); diff --git a/native/chat/chat.react.js b/native/chat/chat.react.js index 5fc89d781..3c1819feb 100644 --- a/native/chat/chat.react.js +++ b/native/chat/chat.react.js @@ -1,260 +1,261 @@ // @flow -import * as React from 'react'; import { createMaterialTopTabNavigator, type MaterialTopTabNavigationProp, } from '@react-navigation/material-top-tabs'; import { createNavigatorFactory, useNavigationBuilder, type StackNavigationState, type StackOptions, type StackNavigationEventMap, type StackNavigatorProps, type ExtraStackNavigatorProps, type StackHeaderProps as CoreStackHeaderProps, } from '@react-navigation/native'; import { StackView, type StackHeaderProps } from '@react-navigation/stack'; -import { Platform, View } from 'react-native'; import invariant from 'invariant'; +import * as React from 'react'; +import { Platform, View } from 'react-native'; -import HomeChatThreadList from './home-chat-thread-list.react'; -import BackgroundChatThreadList from './background-chat-thread-list.react'; -import MessageListContainer from './message-list-container.react'; -import ComposeThread from './compose-thread.react'; -import ThreadSettings from './settings/thread-settings.react'; -import DeleteThread from './settings/delete-thread.react'; +import KeyboardAvoidingView from '../components/keyboard-avoiding-view.react'; +import { InputStateContext } from '../input/input-state'; +import HeaderBackButton from '../navigation/header-back-button.react'; import { ComposeThreadRouteName, DeleteThreadRouteName, ThreadSettingsRouteName, MessageListRouteName, ChatThreadListRouteName, HomeChatThreadListRouteName, BackgroundChatThreadListRouteName, type ScreenParamList, type ChatParamList, type ChatTopTabsParamList, } from '../navigation/route-names'; -import HeaderBackButton from '../navigation/header-back-button.react'; +import { useStyles } from '../themes/colors'; + +import BackgroundChatThreadList from './background-chat-thread-list.react'; import ChatHeader from './chat-header.react'; import ChatRouter, { type ChatRouterNavigationProp } from './chat-router'; -import MessageStorePruner from './message-store-pruner.react'; -import ThreadScreenPruner from './thread-screen-pruner.react'; import ComposeThreadButton from './compose-thread-button.react'; +import ComposeThread from './compose-thread.react'; +import HomeChatThreadList from './home-chat-thread-list.react'; +import MessageListContainer from './message-list-container.react'; import MessageListHeaderTitle from './message-list-header-title.react'; +import MessageStorePruner from './message-store-pruner.react'; +import DeleteThread from './settings/delete-thread.react'; +import ThreadSettings from './settings/thread-settings.react'; +import ThreadScreenPruner from './thread-screen-pruner.react'; import ThreadSettingsButton from './thread-settings-button.react'; -import { InputStateContext } from '../input/input-state'; -import { useStyles } from '../themes/colors'; -import KeyboardAvoidingView from '../components/keyboard-avoiding-view.react'; const unboundStyles = { keyboardAvoidingView: { flex: 1, }, view: { flex: 1, backgroundColor: 'listBackground', }, threadListHeaderStyle: { elevation: 0, shadowOffset: { width: 0, height: 0 }, borderBottomWidth: 0, }, }; export type ChatTopTabsNavigationProp< RouteName: $Keys = $Keys, > = MaterialTopTabNavigationProp; const homeChatThreadListOptions = { title: 'Home', }; const backgroundChatThreadListOptions = { title: 'Background', }; const ChatThreadsTopTab = createMaterialTopTabNavigator(); const ChatThreadsComponent = () => { return ( ); }; type ChatNavigatorProps = StackNavigatorProps>; function ChatNavigator({ initialRouteName, children, screenOptions, ...rest }: ChatNavigatorProps) { const { state, descriptors, navigation } = useNavigationBuilder(ChatRouter, { initialRouteName, children, screenOptions, }); // Clear ComposeThread screens after each message is sent. If a user goes to // ComposeThread to create a new thread, but finds an existing one and uses it // instead, we can assume the intent behind opening ComposeThread is resolved const inputState = React.useContext(InputStateContext); invariant(inputState, 'InputState should be set in ChatNavigator'); const clearComposeScreensAfterMessageSend = React.useCallback(() => { navigation.clearScreens([ComposeThreadRouteName]); }, [navigation]); React.useEffect(() => { inputState.registerSendCallback(clearComposeScreensAfterMessageSend); return () => { inputState.unregisterSendCallback(clearComposeScreensAfterMessageSend); }; }, [inputState, clearComposeScreensAfterMessageSend]); return ( ); } const createChatNavigator = createNavigatorFactory< StackNavigationState, StackOptions, StackNavigationEventMap, ChatRouterNavigationProp<>, ExtraStackNavigatorProps, >(ChatNavigator); const header = (props: CoreStackHeaderProps) => { // Flow has trouble reconciling identical types between different libdefs, // and flow-typed has no way for one libdef to depend on another const castProps: StackHeaderProps = (props: any); return ; }; const headerBackButton = (props) => ; const screenOptions = { header, headerLeft: headerBackButton, gestureEnabled: Platform.OS === 'ios', animationEnabled: Platform.OS !== 'web' && Platform.OS !== 'windows' && Platform.OS !== 'macos', }; const chatThreadListOptions = ({ navigation }) => ({ headerTitle: 'Threads', headerRight: Platform.OS === 'ios' ? () => : undefined, headerBackTitle: 'Back', headerStyle: unboundStyles.threadListHeaderStyle, }); const messageListOptions = ({ navigation, route }) => ({ // This is a render prop, not a component // eslint-disable-next-line react/display-name headerTitle: () => ( ), headerTitleContainerStyle: { marginHorizontal: Platform.select({ ios: 80, default: 0 }), flex: 1, }, headerRight: Platform.OS === 'android' ? // This is a render prop, not a component // eslint-disable-next-line react/display-name () => ( ) : undefined, headerBackTitle: 'Back', }); const composeThreadOptions = { headerTitle: 'Compose thread', headerBackTitle: 'Back', }; const threadSettingsOptions = ({ route }) => ({ headerTitle: route.params.threadInfo.uiName, headerBackTitle: 'Back', }); const deleteThreadOptions = { headerTitle: 'Delete thread', headerBackTitle: 'Back', }; export type ChatNavigationProp< RouteName: $Keys = $Keys, > = ChatRouterNavigationProp; const Chat = createChatNavigator< ScreenParamList, ChatParamList, ChatNavigationProp<>, >(); export default function ChatComponent() { const styles = useStyles(unboundStyles); const behavior = Platform.select({ android: 'height', default: 'padding', }); return ( ); } diff --git a/native/chat/compose-thread-button.react.js b/native/chat/compose-thread-button.react.js index 69c5810d7..57d9ec093 100644 --- a/native/chat/compose-thread-button.react.js +++ b/native/chat/compose-thread-button.react.js @@ -1,58 +1,57 @@ // @flow -import type { ChatNavigationProp } from './chat.react'; -import type { AppState } from '../redux/redux-setup'; - +import { connect } from 'lib/utils/redux-utils'; +import PropTypes from 'prop-types'; import * as React from 'react'; import { StyleSheet } from 'react-native'; import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; -import PropTypes from 'prop-types'; -import { connect } from 'lib/utils/redux-utils'; - -import { ComposeThreadRouteName } from '../navigation/route-names'; import Button from '../components/button.react'; +import { ComposeThreadRouteName } from '../navigation/route-names'; +import type { AppState } from '../redux/redux-setup'; import { type Colors, colorsPropType, colorsSelector } from '../themes/colors'; +import type { ChatNavigationProp } from './chat.react'; + type Props = {| navigate: $PropertyType, 'navigate'>, // Redux state colors: Colors, |}; class ComposeThreadButton extends React.PureComponent { static propTypes = { navigate: PropTypes.func.isRequired, colors: colorsPropType.isRequired, }; render() { const { link: linkColor } = this.props.colors; return ( ); } onPress = () => { this.props.navigate({ name: ComposeThreadRouteName, params: {}, }); }; } const styles = StyleSheet.create({ composeButton: { paddingHorizontal: 10, }, }); export default connect((state: AppState) => ({ colors: colorsSelector(state), }))(ComposeThreadButton); diff --git a/native/chat/compose-thread.react.js b/native/chat/compose-thread.react.js index 198448e7a..ab64666f2 100644 --- a/native/chat/compose-thread.react.js +++ b/native/chat/compose-thread.react.js @@ -1,522 +1,521 @@ // @flow -import type { LoadingStatus } from 'lib/types/loading-types'; +import invariant from 'invariant'; +import { newThreadActionTypes, newThread } from 'lib/actions/thread-actions'; +import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; +import { threadInfoSelector } from 'lib/selectors/thread-selectors'; +import { + userInfoSelectorForPotentialMembers, + userSearchIndexForPotentialMembers, +} from 'lib/selectors/user-selectors'; +import SearchIndex from 'lib/shared/search-index'; +import { getPotentialMemberItems } from 'lib/shared/search-utils'; +import { threadInFilterList, userIsMember } from 'lib/shared/thread-utils'; import { loadingStatusPropType } from 'lib/types/loading-types'; +import type { LoadingStatus } from 'lib/types/loading-types'; import { type ThreadInfo, threadInfoPropType, type ThreadType, threadTypes, threadTypePropType, type NewThreadRequest, type NewThreadResult, } from 'lib/types/thread-types'; import { type AccountUserInfo, accountUserInfoPropType, } from 'lib/types/user-types'; import type { DispatchActionPromise } from 'lib/utils/action-utils'; -import type { ChatNavigationProp } from './chat.react'; -import type { NavigationRoute } from '../navigation/route-names'; - -import * as React from 'react'; -import PropTypes from 'prop-types'; -import { View, Text, Alert } from 'react-native'; -import invariant from 'invariant'; -import _flow from 'lodash/fp/flow'; -import _filter from 'lodash/fp/filter'; -import _sortBy from 'lodash/fp/sortBy'; -import { createSelector } from 'reselect'; - -import { newThreadActionTypes, newThread } from 'lib/actions/thread-actions'; -import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; -import { - userInfoSelectorForPotentialMembers, - userSearchIndexForPotentialMembers, -} from 'lib/selectors/user-selectors'; -import SearchIndex from 'lib/shared/search-index'; -import { threadInFilterList, userIsMember } from 'lib/shared/thread-utils'; -import { getPotentialMemberItems } from 'lib/shared/search-utils'; -import { threadInfoSelector } from 'lib/selectors/thread-selectors'; import { useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; +import _filter from 'lodash/fp/filter'; +import _flow from 'lodash/fp/flow'; +import _sortBy from 'lodash/fp/sortBy'; +import PropTypes from 'prop-types'; +import * as React from 'react'; +import { View, Text, Alert } from 'react-native'; +import { createSelector } from 'reselect'; +import LinkButton from '../components/link-button.react'; import TagInput from '../components/tag-input.react'; -import UserList from '../components/user-list.react'; import ThreadList from '../components/thread-list.react'; -import LinkButton from '../components/link-button.react'; -import { MessageListRouteName } from '../navigation/route-names'; import ThreadVisibility from '../components/thread-visibility.react'; +import UserList from '../components/user-list.react'; +import type { NavigationRoute } from '../navigation/route-names'; +import { MessageListRouteName } from '../navigation/route-names'; +import { useSelector } from '../redux/redux-utils'; import { type Colors, colorsPropType, useColors, useStyles, } from '../themes/colors'; -import { useSelector } from '../redux/redux-utils'; + +import type { ChatNavigationProp } from './chat.react'; const tagInputProps = { placeholder: 'username', autoFocus: true, returnKeyType: 'go', }; export type ComposeThreadParams = {| threadType?: ThreadType, parentThreadInfo?: ThreadInfo, |}; type BaseProps = {| +navigation: ChatNavigationProp<'ComposeThread'>, +route: NavigationRoute<'ComposeThread'>, |}; type Props = {| ...BaseProps, // Redux state +parentThreadInfo: ?ThreadInfo, +loadingStatus: LoadingStatus, +otherUserInfos: { [id: string]: AccountUserInfo }, +userSearchIndex: SearchIndex, +threadInfos: { [id: string]: ThreadInfo }, +colors: Colors, +styles: typeof unboundStyles, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +newThread: (request: NewThreadRequest) => Promise, |}; type State = {| +usernameInputText: string, +userInfoInputArray: $ReadOnlyArray, |}; type PropsAndState = {| ...Props, ...State |}; class ComposeThread extends React.PureComponent { static propTypes = { navigation: PropTypes.shape({ setParams: PropTypes.func.isRequired, setOptions: PropTypes.func.isRequired, navigate: PropTypes.func.isRequired, pushNewThread: PropTypes.func.isRequired, }).isRequired, route: PropTypes.shape({ key: PropTypes.string.isRequired, params: PropTypes.shape({ threadType: threadTypePropType, parentThreadInfo: threadInfoPropType, }).isRequired, }).isRequired, parentThreadInfo: threadInfoPropType, loadingStatus: loadingStatusPropType.isRequired, otherUserInfos: PropTypes.objectOf(accountUserInfoPropType).isRequired, userSearchIndex: PropTypes.instanceOf(SearchIndex).isRequired, threadInfos: PropTypes.objectOf(threadInfoPropType).isRequired, colors: colorsPropType.isRequired, styles: PropTypes.objectOf(PropTypes.object).isRequired, dispatchActionPromise: PropTypes.func.isRequired, newThread: PropTypes.func.isRequired, }; state: State = { usernameInputText: '', userInfoInputArray: [], }; tagInput: ?TagInput; createThreadPressed = false; waitingOnThreadID: ?string; componentDidMount() { this.setLinkButton(true); } setLinkButton(enabled: boolean) { this.props.navigation.setOptions({ headerRight: () => ( ), }); } componentDidUpdate(prevProps: Props) { const oldReduxParentThreadInfo = prevProps.parentThreadInfo; const newReduxParentThreadInfo = this.props.parentThreadInfo; if ( newReduxParentThreadInfo && newReduxParentThreadInfo !== oldReduxParentThreadInfo ) { this.props.navigation.setParams({ parentThreadInfo: newReduxParentThreadInfo, }); } if ( this.waitingOnThreadID && this.props.threadInfos[this.waitingOnThreadID] && !prevProps.threadInfos[this.waitingOnThreadID] ) { const threadInfo = this.props.threadInfos[this.waitingOnThreadID]; this.props.navigation.pushNewThread(threadInfo); } } static getParentThreadInfo(props: { route: NavigationRoute<'ComposeThread'>, }): ?ThreadInfo { return props.route.params.parentThreadInfo; } userSearchResultsSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.usernameInputText, (propsAndState: PropsAndState) => propsAndState.otherUserInfos, (propsAndState: PropsAndState) => propsAndState.userSearchIndex, (propsAndState: PropsAndState) => propsAndState.userInfoInputArray, (propsAndState: PropsAndState) => ComposeThread.getParentThreadInfo(propsAndState), ( text: string, userInfos: { [id: string]: AccountUserInfo }, searchIndex: SearchIndex, userInfoInputArray: $ReadOnlyArray, parentThreadInfo: ?ThreadInfo, ) => getPotentialMemberItems( text, userInfos, searchIndex, userInfoInputArray.map((userInfo) => userInfo.id), parentThreadInfo, ), ); get userSearchResults() { return this.userSearchResultsSelector({ ...this.props, ...this.state }); } existingThreadsSelector = createSelector( (propsAndState: PropsAndState) => ComposeThread.getParentThreadInfo(propsAndState), (propsAndState: PropsAndState) => propsAndState.threadInfos, (propsAndState: PropsAndState) => propsAndState.userInfoInputArray, ( parentThreadInfo: ?ThreadInfo, threadInfos: { [id: string]: ThreadInfo }, userInfoInputArray: $ReadOnlyArray, ) => { const userIDs = userInfoInputArray.map((userInfo) => userInfo.id); if (userIDs.length === 0) { return []; } return _flow( _filter( (threadInfo: ThreadInfo) => threadInFilterList(threadInfo) && (!parentThreadInfo || threadInfo.parentThreadID === parentThreadInfo.id) && userIDs.every((userID) => userIsMember(threadInfo, userID)), ), _sortBy( ([ 'members.length', (threadInfo: ThreadInfo) => (threadInfo.name ? 1 : 0), ]: $ReadOnlyArray mixed)>), ), )(threadInfos); }, ); get existingThreads() { return this.existingThreadsSelector({ ...this.props, ...this.state }); } render() { let existingThreadsSection = null; const { existingThreads, userSearchResults } = this; if (existingThreads.length > 0) { existingThreadsSection = ( Existing threads ); } let parentThreadRow = null; const parentThreadInfo = ComposeThread.getParentThreadInfo(this.props); if (parentThreadInfo) { const threadType = this.props.route.params.threadType; invariant( threadType !== undefined && threadType !== null, `no threadType provided for ${parentThreadInfo.id}`, ); const threadVisibilityColor = this.props.colors.modalForegroundLabel; parentThreadRow = ( within {parentThreadInfo.uiName} ); } const inputProps = { ...tagInputProps, onSubmitEditing: this.onPressCreateThread, }; return ( {parentThreadRow} To: {existingThreadsSection} ); } tagInputRef = (tagInput: ?TagInput) => { this.tagInput = tagInput; }; onChangeTagInput = (userInfoInputArray: $ReadOnlyArray) => { this.setState({ userInfoInputArray }); }; tagDataLabelExtractor = (userInfo: AccountUserInfo) => userInfo.username; setUsernameInputText = (text: string) => { this.setState({ usernameInputText: text }); }; onUserSelect = (userID: string) => { for (let existingUserInfo of this.state.userInfoInputArray) { if (userID === existingUserInfo.id) { return; } } const userInfoInputArray = [ ...this.state.userInfoInputArray, this.props.otherUserInfos[userID], ]; this.setState({ userInfoInputArray, usernameInputText: '', }); }; onPressCreateThread = () => { if (this.createThreadPressed) { return; } if (this.state.userInfoInputArray.length === 0) { Alert.alert( 'Chatting to yourself?', 'Are you sure you want to create a thread containing only yourself?', [ { text: 'Cancel', style: 'cancel' }, { text: 'Confirm', onPress: this.dispatchNewChatThreadAction }, ], { cancelable: true }, ); } else { this.dispatchNewChatThreadAction(); } }; dispatchNewChatThreadAction = async () => { this.createThreadPressed = true; this.props.dispatchActionPromise( newThreadActionTypes, this.newChatThreadAction(), ); }; async newChatThreadAction() { this.setLinkButton(false); try { const threadTypeParam = this.props.route.params.threadType; const threadType = threadTypeParam ? threadTypeParam : threadTypes.CHAT_SECRET; const initialMemberIDs = this.state.userInfoInputArray.map( (userInfo: AccountUserInfo) => userInfo.id, ); const parentThreadInfo = ComposeThread.getParentThreadInfo(this.props); const result = await this.props.newThread({ type: threadType, parentThreadID: parentThreadInfo ? parentThreadInfo.id : null, initialMemberIDs, color: parentThreadInfo ? parentThreadInfo.color : null, }); this.waitingOnThreadID = result.newThreadID; return result; } catch (e) { this.createThreadPressed = false; this.setLinkButton(true); Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: this.onUnknownErrorAlertAcknowledged }], { cancelable: false }, ); throw e; } } onErrorAcknowledged = () => { invariant(this.tagInput, 'tagInput should be set'); this.tagInput.focus(); }; onUnknownErrorAlertAcknowledged = () => { this.setState({ usernameInputText: '' }, this.onErrorAcknowledged); }; onSelectExistingThread = (threadID: string) => { const threadInfo = this.props.threadInfos[threadID]; this.props.navigation.navigate({ name: MessageListRouteName, params: { threadInfo }, key: `${MessageListRouteName}${threadInfo.id}`, }); }; } const unboundStyles = { container: { flex: 1, }, existingThreadList: { backgroundColor: 'modalBackground', flex: 1, paddingRight: 12, }, existingThreads: { flex: 1, }, existingThreadsLabel: { color: 'modalForegroundSecondaryLabel', fontSize: 16, paddingLeft: 12, textAlign: 'center', }, existingThreadsRow: { backgroundColor: 'modalForeground', borderBottomWidth: 1, borderColor: 'modalForegroundBorder', borderTopWidth: 1, paddingVertical: 6, }, listItem: { color: 'modalForegroundLabel', }, parentThreadLabel: { color: 'modalSubtextLabel', fontSize: 16, paddingLeft: 6, }, parentThreadName: { color: 'modalForegroundLabel', fontSize: 16, paddingLeft: 6, }, parentThreadRow: { alignItems: 'center', backgroundColor: 'modalSubtext', flexDirection: 'row', paddingLeft: 12, paddingVertical: 6, }, tagInputContainer: { flex: 1, marginLeft: 8, paddingRight: 12, }, tagInputLabel: { color: 'modalForegroundSecondaryLabel', fontSize: 16, paddingLeft: 12, }, userList: { backgroundColor: 'modalBackground', flex: 1, paddingLeft: 35, paddingRight: 12, }, userSelectionRow: { alignItems: 'center', backgroundColor: 'modalForeground', borderBottomWidth: 1, borderColor: 'modalForegroundBorder', flexDirection: 'row', paddingVertical: 6, }, }; export default React.memo(function ConnectedComposeThread( props: BaseProps, ) { const parentThreadInfoID = props.route.params.parentThreadInfo?.id; const reduxParentThreadInfo = useSelector((state) => parentThreadInfoID ? threadInfoSelector(state)[parentThreadInfoID] : null, ); const loadingStatus = useSelector( createLoadingStatusSelector(newThreadActionTypes), ); const otherUserInfos = useSelector(userInfoSelectorForPotentialMembers); const userSearchIndex = useSelector(userSearchIndexForPotentialMembers); const threadInfos = useSelector(threadInfoSelector); const colors = useColors(); const styles = useStyles(unboundStyles); const dispatchActionPromise = useDispatchActionPromise(); const callNewThread = useServerCall(newThread); return ( ); }); diff --git a/native/chat/composed-message-width.js b/native/chat/composed-message-width.js index adcc112f1..6366fb774 100644 --- a/native/chat/composed-message-width.js +++ b/native/chat/composed-message-width.js @@ -1,21 +1,21 @@ // @flow -import type { AppState } from '../redux/redux-setup'; -import type { DimensionsInfo } from '../redux/dimensions-updater.react'; - import { createSelector } from 'reselect'; +import type { DimensionsInfo } from '../redux/dimensions-updater.react'; +import type { AppState } from '../redux/redux-setup'; + // Keep sorta synced with styles.alignment/styles.messageBox in ComposedMessage const composedMessageMaxWidthSelector: ( state: AppState, ) => number = createSelector( (state: AppState) => state.dimensions, (dimensionsInfo: DimensionsInfo): number => { const windowWidth = dimensionsInfo.rotated ? dimensionsInfo.height : dimensionsInfo.width; return (windowWidth - 24) * 0.8; }, ); export { composedMessageMaxWidthSelector }; diff --git a/native/chat/composed-message.react.js b/native/chat/composed-message.react.js index 9b386caad..a99c22227 100644 --- a/native/chat/composed-message.react.js +++ b/native/chat/composed-message.react.js @@ -1,189 +1,188 @@ // @flow -import type { ChatMessageInfoItemWithHeight } from './message.react'; +import invariant from 'invariant'; import { chatMessageItemPropType } from 'lib/selectors/chat-selectors'; +import { createMessageReply } from 'lib/shared/message-utils'; import { assertComposableMessageType } from 'lib/types/message-types'; - -import * as React from 'react'; import PropTypes from 'prop-types'; +import * as React from 'react'; import { StyleSheet, View, Platform } from 'react-native'; import Icon from 'react-native-vector-icons/Feather'; -import invariant from 'invariant'; - -import { createMessageReply } from 'lib/shared/message-utils'; -import SwipeableMessage from './swipeable-message.react'; -import { FailedSend } from './failed-send.react'; -import { composedMessageMaxWidthSelector } from './composed-message-width'; -import { MessageHeader } from './message-header.react'; -import { type Colors, colorsPropType, useColors } from '../themes/colors'; import { inputStatePropType, type InputState, InputStateContext, } from '../input/input-state'; import { useSelector } from '../redux/redux-utils'; +import { type Colors, colorsPropType, useColors } from '../themes/colors'; + +import { composedMessageMaxWidthSelector } from './composed-message-width'; +import { FailedSend } from './failed-send.react'; +import { MessageHeader } from './message-header.react'; +import type { ChatMessageInfoItemWithHeight } from './message.react'; +import SwipeableMessage from './swipeable-message.react'; const clusterEndHeight = 7; type BaseProps = {| ...React.ElementConfig, +item: ChatMessageInfoItemWithHeight, +sendFailed: boolean, +focused: boolean, +canSwipe?: boolean, +children: React.Node, |}; type Props = {| ...BaseProps, // Redux state +composedMessageMaxWidth: number, +colors: Colors, // withInputState +inputState: ?InputState, |}; class ComposedMessage extends React.PureComponent { static propTypes = { item: chatMessageItemPropType.isRequired, sendFailed: PropTypes.bool.isRequired, focused: PropTypes.bool.isRequired, canSwipe: PropTypes.bool, children: PropTypes.node.isRequired, composedMessageMaxWidth: PropTypes.number.isRequired, colors: colorsPropType.isRequired, inputState: inputStatePropType, }; render() { assertComposableMessageType(this.props.item.messageInfo.type); const { item, sendFailed, focused, canSwipe, children, composedMessageMaxWidth, colors, inputState, ...viewProps } = this.props; const { id, creator } = item.messageInfo; const { isViewer } = creator; const alignStyle = isViewer ? styles.rightChatBubble : styles.leftChatBubble; const containerStyle = [ styles.alignment, { marginBottom: 5 + (item.endsCluster ? clusterEndHeight : 0) }, ]; const messageBoxStyle = { maxWidth: composedMessageMaxWidth }; let deliveryIcon = null; let failedSendInfo = null; if (isViewer) { let deliveryIconName; let deliveryIconColor = `#${item.threadInfo.color}`; if (id !== null && id !== undefined) { deliveryIconName = 'check-circle'; } else if (sendFailed) { deliveryIconName = 'x-circle'; deliveryIconColor = colors.redText; failedSendInfo = ; } else { deliveryIconName = 'circle'; } deliveryIcon = ( ); } const fullMessageBoxStyle = [styles.messageBox, messageBoxStyle]; let messageBox; if (canSwipe && (Platform.OS !== 'android' || Platform.Version >= 21)) { messageBox = ( {children} ); } else { messageBox = {children}; } return ( {messageBox} {deliveryIcon} {failedSendInfo} ); } reply = () => { const { inputState, item } = this.props; invariant(inputState, 'inputState should be set in reply'); invariant(item.messageInfo.text, 'text should be set in reply'); inputState.addReply(createMessageReply(item.messageInfo.text)); }; } const styles = StyleSheet.create({ alignment: { marginLeft: 12, marginRight: 7, }, content: { alignItems: 'center', flexDirection: 'row', }, icon: { fontSize: 16, textAlign: 'center', }, iconContainer: { marginLeft: 2, width: 16, }, leftChatBubble: { justifyContent: 'flex-start', }, messageBox: { marginRight: 5, }, rightChatBubble: { justifyContent: 'flex-end', }, }); const ConnectedComposedMessage = React.memo( function ConnectedComposedMessage(props: BaseProps) { const composedMessageMaxWidth = useSelector( composedMessageMaxWidthSelector, ); const colors = useColors(); const inputState = React.useContext(InputStateContext); return ( ); }, ); export { ConnectedComposedMessage as ComposedMessage, clusterEndHeight }; diff --git a/native/chat/failed-send.react.js b/native/chat/failed-send.react.js index 404ed3ecf..49adb9a17 100644 --- a/native/chat/failed-send.react.js +++ b/native/chat/failed-send.react.js @@ -1,171 +1,170 @@ // @flow -import type { ChatMessageInfoItemWithHeight } from './message.react'; +import invariant from 'invariant'; import { chatMessageItemPropType } from 'lib/selectors/chat-selectors'; +import { messageID } from 'lib/shared/message-utils'; import { messageTypes, type RawMessageInfo } from 'lib/types/message-types'; +import PropTypes from 'prop-types'; +import * as React from 'react'; +import { Text, View } from 'react-native'; + +import Button from '../components/button.react'; import { type InputState, inputStatePropType, InputStateContext, } from '../input/input-state'; - -import * as React from 'react'; -import { Text, View } from 'react-native'; -import invariant from 'invariant'; -import PropTypes from 'prop-types'; - -import { messageID } from 'lib/shared/message-utils'; - -import Button from '../components/button.react'; +import { useSelector } from '../redux/redux-utils'; import { useStyles } from '../themes/colors'; + +import type { ChatMessageInfoItemWithHeight } from './message.react'; import multimediaMessageSendFailed from './multimedia-message-send-failed'; import textMessageSendFailed from './text-message-send-failed'; -import { useSelector } from '../redux/redux-utils'; const failedSendHeight = 22; type BaseProps = {| +item: ChatMessageInfoItemWithHeight, |}; type Props = {| ...BaseProps, // Redux state +rawMessageInfo: ?RawMessageInfo, +styles: typeof unboundStyles, // withInputState +inputState: ?InputState, |}; class FailedSend extends React.PureComponent { static propTypes = { item: chatMessageItemPropType.isRequired, rawMessageInfo: PropTypes.object, styles: PropTypes.objectOf(PropTypes.object).isRequired, inputState: inputStatePropType, }; retryingText = false; retryingMedia = false; componentDidUpdate(prevProps: Props) { const newItem = this.props.item; const prevItem = prevProps.item; if ( newItem.messageShapeType === 'multimedia' && prevItem.messageShapeType === 'multimedia' ) { const isFailed = multimediaMessageSendFailed(newItem); const wasFailed = multimediaMessageSendFailed(prevItem); const isDone = newItem.messageInfo.id !== null && newItem.messageInfo.id !== undefined; const wasDone = prevItem.messageInfo.id !== null && prevItem.messageInfo.id !== undefined; if ((isFailed && !wasFailed) || (isDone && !wasDone)) { this.retryingMedia = false; } } else if ( newItem.messageShapeType === 'text' && prevItem.messageShapeType === 'text' ) { const isFailed = textMessageSendFailed(newItem); const wasFailed = textMessageSendFailed(prevItem); const isDone = newItem.messageInfo.id !== null && newItem.messageInfo.id !== undefined; const wasDone = prevItem.messageInfo.id !== null && prevItem.messageInfo.id !== undefined; if ((isFailed && !wasFailed) || (isDone && !wasDone)) { this.retryingText = false; } } } render() { if (!this.props.rawMessageInfo) { return null; } return ( DELIVERY FAILED. ); } retrySend = () => { const { rawMessageInfo } = this.props; if (!rawMessageInfo) { return; } const { inputState } = this.props; invariant( inputState, 'inputState should be initialized before user can hit retry', ); if (rawMessageInfo.type === messageTypes.TEXT) { if (this.retryingText) { return; } this.retryingText = true; inputState.sendTextMessage({ ...rawMessageInfo, time: Date.now(), }); } else if ( rawMessageInfo.type === messageTypes.IMAGES || rawMessageInfo.type === messageTypes.MULTIMEDIA ) { const { localID } = rawMessageInfo; invariant(localID, 'failed RawMessageInfo should have localID'); if (this.retryingMedia) { return; } this.retryingMedia = true; inputState.retryMultimediaMessage(localID); } }; } const unboundStyles = { deliveryFailed: { color: 'listSeparatorLabel', paddingHorizontal: 3, }, failedSendInfo: { flex: 1, flexDirection: 'row', justifyContent: 'flex-end', marginRight: 20, paddingTop: 5, }, retrySend: { color: 'link', paddingHorizontal: 3, }, }; const ConnectedFailedSend = React.memo(function ConnectedFailedSend( props: BaseProps, ) { const id = messageID(props.item.messageInfo); const rawMessageInfo = useSelector( (state) => state.messageStore.messages[id], ); const styles = useStyles(unboundStyles); const inputState = React.useContext(InputStateContext); return ( ); }); export { ConnectedFailedSend as FailedSend, failedSendHeight }; diff --git a/native/chat/home-chat-thread-list.react.js b/native/chat/home-chat-thread-list.react.js index 6a3824548..43df1f264 100644 --- a/native/chat/home-chat-thread-list.react.js +++ b/native/chat/home-chat-thread-list.react.js @@ -1,24 +1,23 @@ // @flow -import type { ChatTopTabsNavigationProp } from './chat.react'; -import type { NavigationRoute } from '../navigation/route-names'; - +import { threadInHomeChatList } from 'lib/shared/thread-utils'; import * as React from 'react'; -import { threadInHomeChatList } from 'lib/shared/thread-utils'; +import type { NavigationRoute } from '../navigation/route-names'; import ChatThreadList from './chat-thread-list.react'; +import type { ChatTopTabsNavigationProp } from './chat.react'; type HomeChatThreadListProps = {| navigation: ChatTopTabsNavigationProp<'HomeChatThreadList'>, route: NavigationRoute<'HomeChatThreadList'>, |}; export default function HomeChatThreadList(props: HomeChatThreadListProps) { return ( ); } diff --git a/native/chat/inline-multimedia.react.js b/native/chat/inline-multimedia.react.js index 6362bf430..80ac254bb 100644 --- a/native/chat/inline-multimedia.react.js +++ b/native/chat/inline-multimedia.react.js @@ -1,103 +1,102 @@ // @flow import type { MediaInfo } from 'lib/types/media-types'; -import type { PendingMultimediaUpload } from '../input/input-state'; - import * as React from 'react'; import { View, StyleSheet } from 'react-native'; -import Icon from 'react-native-vector-icons/Feather'; import * as Progress from 'react-native-progress'; +import Icon from 'react-native-vector-icons/Feather'; -import Multimedia from '../media/multimedia.react'; import GestureTouchableOpacity from '../components/gesture-touchable-opacity.react'; +import type { PendingMultimediaUpload } from '../input/input-state'; import { KeyboardContext } from '../keyboard/keyboard-state'; +import Multimedia from '../media/multimedia.react'; const formatProgressText = (progress: number) => `${Math.floor(progress * 100)}%`; type Props = {| +mediaInfo: MediaInfo, +onPress: () => void, +onLongPress: () => void, +postInProgress: boolean, +pendingUpload: ?PendingMultimediaUpload, +spinnerColor: string, |}; function InlineMultimedia(props: Props) { const { mediaInfo, pendingUpload, postInProgress } = props; let failed = mediaInfo.id.startsWith('localUpload') && !postInProgress; let progressPercent = 1; if (pendingUpload) { ({ progressPercent, failed } = pendingUpload); } let progressIndicator; if (failed) { progressIndicator = ( ); } else if (progressPercent !== 1) { progressIndicator = ( ); } const keyboardState = React.useContext(KeyboardContext); const keyboardShowing = keyboardState?.keyboardShowing; return ( {progressIndicator} ); } const styles = StyleSheet.create({ centerContainer: { alignItems: 'center', bottom: 0, justifyContent: 'center', left: 0, position: 'absolute', right: 0, top: 0, }, expand: { flex: 1, }, progressIndicatorText: { color: 'black', fontSize: 21, }, uploadError: { color: 'white', textShadowColor: '#000', textShadowOffset: { width: 0, height: 1 }, textShadowRadius: 1, }, }); export default InlineMultimedia; diff --git a/native/chat/inner-text-message.react.js b/native/chat/inner-text-message.react.js index 3813396e0..352f9c1d2 100644 --- a/native/chat/inner-text-message.react.js +++ b/native/chat/inner-text-message.react.js @@ -1,140 +1,139 @@ // @flow -import type { ChatTextMessageInfoItemWithHeight } from './text-message.react'; - +import invariant from 'invariant'; +import { colorIsDark } from 'lib/shared/thread-utils'; import * as React from 'react'; import { View, StyleSheet } from 'react-native'; -import invariant from 'invariant'; -import { colorIsDark } from 'lib/shared/thread-utils'; +import GestureTouchableOpacity from '../components/gesture-touchable-opacity.react'; +import { KeyboardContext } from '../keyboard/keyboard-state'; +import Markdown from '../markdown/markdown.react'; +import { useSelector } from '../redux/redux-utils'; +import { useColors, colors } from '../themes/colors'; +import { composedMessageMaxWidthSelector } from './composed-message-width'; +import { MessageListContext } from './message-list-types'; import { allCorners, filterCorners, getRoundedContainerStyle, } from './rounded-corners'; -import { useColors, colors } from '../themes/colors'; -import Markdown from '../markdown/markdown.react'; -import { composedMessageMaxWidthSelector } from './composed-message-width'; -import GestureTouchableOpacity from '../components/gesture-touchable-opacity.react'; -import { useSelector } from '../redux/redux-utils'; -import { KeyboardContext } from '../keyboard/keyboard-state'; -import { MessageListContext } from './message-list-types'; +import type { ChatTextMessageInfoItemWithHeight } from './text-message.react'; function useTextMessageMarkdownRules(useDarkStyle: boolean) { const messageListContext = React.useContext(MessageListContext); invariant(messageListContext, 'DummyTextNode should have MessageListContext'); return messageListContext.getTextMessageMarkdownRules(useDarkStyle); } function dummyNodeForTextMessageHeightMeasurement(text: string) { return {text}; } type DummyTextNodeProps = {| ...React.ElementConfig, +children: string, |}; function DummyTextNode(props: DummyTextNodeProps) { const { children, style, ...rest } = props; const maxWidth = useSelector((state) => composedMessageMaxWidthSelector(state), ); const viewStyle = [props.style, styles.dummyMessage, { maxWidth }]; const rules = useTextMessageMarkdownRules(false); return ( {children} ); } type Props = {| +item: ChatTextMessageInfoItemWithHeight, +onPress: () => void, +messageRef?: (message: ?React.ElementRef) => void, |}; function InnerTextMessage(props: Props) { const { item } = props; const { text, creator } = item.messageInfo; const { isViewer } = creator; const activeTheme = useSelector((state) => state.globalThemeInfo.activeTheme); const boundColors = useColors(); let messageStyle = {}, textStyle = {}, darkColor; if (isViewer) { const threadColor = item.threadInfo.color; messageStyle.backgroundColor = `#${threadColor}`; darkColor = colorIsDark(threadColor); } else { messageStyle.backgroundColor = boundColors.listChatBubble; darkColor = activeTheme === 'dark'; } textStyle.color = darkColor ? colors.dark.listForegroundLabel : colors.light.listForegroundLabel; const cornerStyle = getRoundedContainerStyle(filterCorners(allCorners, item)); if (!__DEV__) { // We don't force view height in dev mode because we // want to measure it in Message to see if it's correct messageStyle.height = item.contentHeight; } const keyboardState = React.useContext(KeyboardContext); const keyboardShowing = keyboardState?.keyboardShowing; const rules = useTextMessageMarkdownRules(darkColor); const message = ( {text} ); // We need to set onLayout in order to allow .measure() to be on the ref const onLayout = React.useCallback(() => {}, []); const { messageRef } = props; if (!messageRef) { return message; } return ( {message} ); } const styles = StyleSheet.create({ dummyMessage: { paddingHorizontal: 12, paddingVertical: 6, }, message: { overflow: 'hidden', paddingHorizontal: 12, paddingVertical: 6, }, text: { fontFamily: 'Arial', fontSize: 18, }, }); export { InnerTextMessage, dummyNodeForTextMessageHeightMeasurement }; diff --git a/native/chat/message-header.react.js b/native/chat/message-header.react.js index b540acb71..b00275480 100644 --- a/native/chat/message-header.react.js +++ b/native/chat/message-header.react.js @@ -1,91 +1,90 @@ // @flow -import type { ChatMessageInfoItemWithHeight } from './message.react'; -import type { AppState } from '../redux/redux-setup'; -import type { DisplayType } from './timestamp.react'; - -import * as React from 'react'; -import { View } from 'react-native'; - import { stringForUser } from 'lib/shared/user-utils'; import { connect } from 'lib/utils/redux-utils'; +import * as React from 'react'; +import { View } from 'react-native'; -import { Timestamp, timestampHeight } from './timestamp.react'; +import { SingleLine } from '../components/single-line.react'; +import type { AppState } from '../redux/redux-setup'; import { styleSelector } from '../themes/colors'; + import { clusterEndHeight } from './composed-message.react'; -import { SingleLine } from '../components/single-line.react'; +import type { ChatMessageInfoItemWithHeight } from './message.react'; +import type { DisplayType } from './timestamp.react'; +import { Timestamp, timestampHeight } from './timestamp.react'; type Props = {| item: ChatMessageInfoItemWithHeight, focused: boolean, display: DisplayType, // Redux state styles: typeof styles, |}; function MessageHeader(props: Props) { const { item, focused, display } = props; const { creator, time } = item.messageInfo; const { isViewer } = creator; const modalDisplay = display === 'modal'; let authorName = null; if (!isViewer && (modalDisplay || item.startsCluster)) { const style = [props.styles.authorName]; if (modalDisplay) { style.push(props.styles.modal); } authorName = ( {stringForUser(creator)} ); } const timestamp = modalDisplay || item.startsConversation ? ( ) : null; let style = null; if (focused && !modalDisplay) { let topMargin = 0; if (!item.startsCluster && !item.messageInfo.creator.isViewer) { topMargin += authorNameHeight + clusterEndHeight; } if (!item.startsConversation) { topMargin += timestampHeight; } style = { marginTop: topMargin }; } return ( {timestamp} {authorName} ); } const authorNameHeight = 25; const styles = { authorName: { bottom: 0, color: 'listBackgroundSecondaryLabel', fontSize: 14, height: authorNameHeight, marginLeft: 12, marginRight: 7, paddingHorizontal: 12, paddingVertical: 4, }, modal: { // high contrast framed against OverlayNavigator-dimmed background color: 'white', }, }; const stylesSelector = styleSelector(styles); const ConnectedMessageHeader = connect((state: AppState) => ({ styles: stylesSelector(state), }))(MessageHeader); export { ConnectedMessageHeader as MessageHeader, authorNameHeight }; diff --git a/native/chat/message-list-container.react.js b/native/chat/message-list-container.react.js index ed1b3b865..8fd860adc 100644 --- a/native/chat/message-list-container.react.js +++ b/native/chat/message-list-container.react.js @@ -1,301 +1,300 @@ // @flow -import { messageTypes } from 'lib/types/message-types'; -import type { ThreadInfo } from 'lib/types/thread-types'; -import type { ChatMessageInfoItemWithHeight } from './message.react'; -import type { ChatNavigationProp } from './chat.react'; -import type { NavigationRoute } from '../navigation/route-names'; - -import * as React from 'react'; -import { View } from 'react-native'; import invariant from 'invariant'; - -import { possiblyPendingThreadInfoSelector } from 'lib/selectors/thread-selectors'; import { type ChatMessageItem, messageListData, } from 'lib/selectors/chat-selectors'; +import { possiblyPendingThreadInfoSelector } from 'lib/selectors/thread-selectors'; import { messageID } from 'lib/shared/message-utils'; +import { messageTypes } from 'lib/types/message-types'; +import type { ThreadInfo } from 'lib/types/thread-types'; +import * as React from 'react'; +import { View } from 'react-native'; -import MessageList from './message-list.react'; +import ContentLoading from '../components/content-loading.react'; import NodeHeightMeasurer from '../components/node-height-measurer.react'; -import ChatInputBar from './chat-input-bar.react'; -import { multimediaMessageContentSizes } from './multimedia-message.react'; -import { composedMessageMaxWidthSelector } from './composed-message-width'; import { type InputState, InputStateContext } from '../input/input-state'; -import { type Colors, useColors, useStyles } from '../themes/colors'; -import ContentLoading from '../components/content-loading.react'; -import { dummyNodeForTextMessageHeightMeasurement } from './inner-text-message.react'; -import { dummyNodeForRobotextMessageHeightMeasurement } from './robotext-message.react'; -import { chatMessageItemKey } from './chat-list.react'; import { OverlayContext, type OverlayContextType, } from '../navigation/overlay-context'; +import type { NavigationRoute } from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils'; +import { type Colors, useColors, useStyles } from '../themes/colors'; + +import ChatInputBar from './chat-input-bar.react'; +import { chatMessageItemKey } from './chat-list.react'; +import type { ChatNavigationProp } from './chat.react'; +import { composedMessageMaxWidthSelector } from './composed-message-width'; +import { dummyNodeForTextMessageHeightMeasurement } from './inner-text-message.react'; import { MessageListContext, useMessageListContext, } from './message-list-types'; +import MessageList from './message-list.react'; +import type { ChatMessageInfoItemWithHeight } from './message.react'; +import { multimediaMessageContentSizes } from './multimedia-message.react'; +import { dummyNodeForRobotextMessageHeightMeasurement } from './robotext-message.react'; export type ChatMessageItemWithHeight = | {| itemType: 'loader' |} | ChatMessageInfoItemWithHeight; type BaseProps = {| +navigation: ChatNavigationProp<'MessageList'>, +route: NavigationRoute<'MessageList'>, |}; type Props = {| ...BaseProps, // Redux state +threadInfo: ?ThreadInfo, +messageListData: $ReadOnlyArray, +composedMessageMaxWidth: number, +colors: Colors, +styles: typeof unboundStyles, // withInputState +inputState: ?InputState, // withOverlayContext +overlayContext: ?OverlayContextType, |}; type State = {| +listDataWithHeights: ?$ReadOnlyArray, |}; class MessageListContainer extends React.PureComponent { state: State = { listDataWithHeights: null, }; pendingListDataWithHeights: ?$ReadOnlyArray; static getThreadInfo(props: Props): ThreadInfo { const { threadInfo } = props; if (threadInfo) { return threadInfo; } return props.route.params.threadInfo; } get frozen() { const { overlayContext } = this.props; invariant( overlayContext, 'MessageListContainer should have OverlayContext', ); return overlayContext.scrollBlockingModalStatus !== 'closed'; } componentDidUpdate(prevProps: Props) { const oldReduxThreadInfo = prevProps.threadInfo; const newReduxThreadInfo = this.props.threadInfo; if (newReduxThreadInfo && newReduxThreadInfo !== oldReduxThreadInfo) { this.props.navigation.setParams({ threadInfo: newReduxThreadInfo }); } const oldListData = prevProps.messageListData; const newListData = this.props.messageListData; if (!newListData && oldListData) { this.setState({ listDataWithHeights: null }); } if (!this.frozen && this.pendingListDataWithHeights) { this.setState({ listDataWithHeights: this.pendingListDataWithHeights }); this.pendingListDataWithHeights = undefined; } } render() { const threadInfo = MessageListContainer.getThreadInfo(this.props); const { listDataWithHeights } = this.state; let messageList; if (listDataWithHeights) { messageList = ( ); } else { messageList = ( ); } return ( {messageList} ); } heightMeasurerID = (item: ChatMessageItem) => { return chatMessageItemKey(item); }; heightMeasurerKey = (item: ChatMessageItem) => { if (item.itemType !== 'message') { return null; } const { messageInfo } = item; if (messageInfo.type === messageTypes.TEXT) { return messageInfo.text; } else if (item.robotext && typeof item.robotext === 'string') { return item.robotext; } return null; }; heightMeasurerDummy = (item: ChatMessageItem) => { invariant( item.itemType === 'message', 'NodeHeightMeasurer asked for dummy for non-message item', ); const { messageInfo } = item; if (messageInfo.type === messageTypes.TEXT) { return dummyNodeForTextMessageHeightMeasurement(messageInfo.text); } else if (item.robotext && typeof item.robotext === 'string') { return dummyNodeForRobotextMessageHeightMeasurement(item.robotext); } invariant(false, 'NodeHeightMeasurer asked for dummy for non-text message'); }; heightMeasurerMergeItem = (item: ChatMessageItem, height: ?number) => { if (item.itemType !== 'message') { return item; } const { messageInfo } = item; const threadInfo = MessageListContainer.getThreadInfo(this.props); if ( messageInfo.type === messageTypes.IMAGES || messageInfo.type === messageTypes.MULTIMEDIA ) { const { inputState } = this.props; // Conditional due to Flow... const localMessageInfo = item.localMessageInfo ? item.localMessageInfo : null; const id = messageID(messageInfo); const pendingUploads = inputState && inputState.pendingUploads && inputState.pendingUploads[id]; const sizes = multimediaMessageContentSizes( messageInfo, this.props.composedMessageMaxWidth, ); return { itemType: 'message', messageShapeType: 'multimedia', messageInfo, localMessageInfo, threadInfo, startsConversation: item.startsConversation, startsCluster: item.startsCluster, endsCluster: item.endsCluster, pendingUploads, ...sizes, }; } invariant(height !== null && height !== undefined, 'height should be set'); if (messageInfo.type === messageTypes.TEXT) { // Conditional due to Flow... const localMessageInfo = item.localMessageInfo ? item.localMessageInfo : null; return { itemType: 'message', messageShapeType: 'text', messageInfo, localMessageInfo, threadInfo, startsConversation: item.startsConversation, startsCluster: item.startsCluster, endsCluster: item.endsCluster, contentHeight: height, }; } else { invariant( typeof item.robotext === 'string', "Flow can't handle our fancy types :(", ); return { itemType: 'message', messageShapeType: 'robotext', messageInfo, threadInfo, startsConversation: item.startsConversation, startsCluster: item.startsCluster, endsCluster: item.endsCluster, robotext: item.robotext, contentHeight: height, }; } }; allHeightsMeasured = ( listDataWithHeights: $ReadOnlyArray, ) => { if (this.frozen) { this.pendingListDataWithHeights = listDataWithHeights; } else { this.setState({ listDataWithHeights }); } }; } const unboundStyles = { container: { backgroundColor: 'listBackground', flex: 1, }, }; export default React.memo(function ConnectedMessageListContainer( props: BaseProps, ) { const threadInfo = useSelector( possiblyPendingThreadInfoSelector(props.route.params.threadInfo), ); const threadID = threadInfo?.id ?? props.route.params.threadInfo.id; const boundMessageListData = useSelector(messageListData(threadID)); const composedMessageMaxWidth = useSelector(composedMessageMaxWidthSelector); const colors = useColors(); const styles = useStyles(unboundStyles); const inputState = React.useContext(InputStateContext); const overlayContext = React.useContext(OverlayContext); const messageListContext = useMessageListContext(threadID); return ( ); }); diff --git a/native/chat/message-list-header-title.react.js b/native/chat/message-list-header-title.react.js index 55c34e07c..68e501d5b 100644 --- a/native/chat/message-list-header-title.react.js +++ b/native/chat/message-list-header-title.react.js @@ -1,110 +1,109 @@ // @flow +import { HeaderTitle } from '@react-navigation/stack'; +import { threadIsPersonalAndPending } from 'lib/shared/thread-utils'; import type { ThreadInfo } from 'lib/types/thread-types'; import { threadInfoPropType } from 'lib/types/thread-types'; -import type { AppState } from '../redux/redux-setup'; -import type { ChatNavigationProp } from './chat.react'; - +import { connect } from 'lib/utils/redux-utils'; +import PropTypes from 'prop-types'; import * as React from 'react'; import { View, Platform } from 'react-native'; -import PropTypes from 'prop-types'; import Icon from 'react-native-vector-icons/Ionicons'; -import { HeaderTitle } from '@react-navigation/stack'; - -import { connect } from 'lib/utils/redux-utils'; import Button from '../components/button.react'; import { ThreadSettingsRouteName } from '../navigation/route-names'; +import type { AppState } from '../redux/redux-setup'; import { styleSelector } from '../themes/colors'; -import { threadIsPersonalAndPending } from 'lib/shared/thread-utils'; + +import type { ChatNavigationProp } from './chat.react'; type Props = {| threadInfo: ThreadInfo, navigate: $PropertyType, 'navigate'>, // Redux state styles: typeof styles, |}; class MessageListHeaderTitle extends React.PureComponent { static propTypes = { threadInfo: threadInfoPropType.isRequired, navigate: PropTypes.func.isRequired, styles: PropTypes.objectOf(PropTypes.object).isRequired, }; render() { let icon, fakeIcon; const areSettingsDisabled = threadIsPersonalAndPending( this.props.threadInfo, ); if (Platform.OS === 'ios' && !areSettingsDisabled) { icon = ( ); fakeIcon = ( ); } return ( ); } onPress = () => { const threadInfo = this.props.threadInfo; this.props.navigate({ name: ThreadSettingsRouteName, params: { threadInfo }, key: `${ThreadSettingsRouteName}${threadInfo.id}`, }); }; } const styles = { button: { flex: 1, }, container: { flex: 1, flexDirection: 'row', alignItems: 'center', justifyContent: Platform.OS === 'android' ? 'flex-start' : 'center', }, fakeIcon: { paddingRight: 7, paddingTop: 3, flex: 1, minWidth: 25, opacity: 0, }, forwardIcon: { paddingLeft: 7, paddingTop: 3, color: 'link', flex: 1, minWidth: 25, }, }; const stylesSelector = styleSelector(styles); export default connect((state: AppState) => ({ styles: stylesSelector(state), }))(MessageListHeaderTitle); diff --git a/native/chat/message-list-types.js b/native/chat/message-list-types.js index e94aba266..d20cd09c2 100644 --- a/native/chat/message-list-types.js +++ b/native/chat/message-list-types.js @@ -1,55 +1,54 @@ // @flow import { type ThreadInfo, threadInfoPropType } from 'lib/types/thread-types'; -import type { MarkdownRules } from '../markdown/rules.react'; import { type UserInfo, userInfoPropType } from 'lib/types/user-types'; - import PropTypes from 'prop-types'; import * as React from 'react'; +import type { MarkdownRules } from '../markdown/rules.react'; import { useTextMessageRulesFunc } from '../markdown/rules.react'; export type MessageListParams = {| threadInfo: ThreadInfo, pendingPersonalThreadUserInfo?: UserInfo, |}; const messageListRoutePropType = PropTypes.shape({ key: PropTypes.string.isRequired, params: PropTypes.shape({ threadInfo: threadInfoPropType.isRequired, pendingPersonalThreadUserInfo: userInfoPropType, }).isRequired, }); const messageListNavPropType = PropTypes.shape({ navigate: PropTypes.func.isRequired, setParams: PropTypes.func.isRequired, setOptions: PropTypes.func.isRequired, dangerouslyGetParent: PropTypes.func.isRequired, isFocused: PropTypes.func.isRequired, popToTop: PropTypes.func.isRequired, }); export type MessageListContextType = {| +getTextMessageMarkdownRules: (useDarkStyle: boolean) => MarkdownRules, |}; const MessageListContext = React.createContext(); function useMessageListContext(threadID: string) { const getTextMessageMarkdownRules = useTextMessageRulesFunc(threadID); return React.useMemo( () => ({ getTextMessageMarkdownRules, }), [getTextMessageMarkdownRules], ); } export { messageListRoutePropType, messageListNavPropType, MessageListContext, useMessageListContext, }; diff --git a/native/chat/message-list.react.js b/native/chat/message-list.react.js index ecc88ffae..d6581b67b 100644 --- a/native/chat/message-list.react.js +++ b/native/chat/message-list.react.js @@ -1,435 +1,434 @@ // @flow -import { - type ThreadInfo, - threadInfoPropType, - threadTypes, -} from 'lib/types/thread-types'; -import { chatMessageItemPropType } from 'lib/selectors/chat-selectors'; -import type { ViewToken } from '../types/react-native'; -import type { FetchMessageInfosPayload } from 'lib/types/message-types'; -import type { ChatMessageItemWithHeight } from './message-list-container.react'; -import type { VerticalBounds } from '../types/layout-types'; -import { - messageListRoutePropType, - messageListNavPropType, -} from './message-list-types'; -import type { ChatNavigationProp } from './chat.react'; -import type { NavigationRoute } from '../navigation/route-names'; - -import * as React from 'react'; -import PropTypes from 'prop-types'; -import { View, TouchableWithoutFeedback } from 'react-native'; -import _find from 'lodash/fp/find'; -import { createSelector } from 'reselect'; import invariant from 'invariant'; - -import { messageKey } from 'lib/shared/message-utils'; import { fetchMessagesBeforeCursorActionTypes, fetchMessagesBeforeCursor, fetchMostRecentMessagesActionTypes, fetchMostRecentMessages, } from 'lib/actions/message-actions'; -import threadWatcher from 'lib/shared/thread-watcher'; -import { threadInChatList } from 'lib/shared/thread-utils'; import { registerFetchKey } from 'lib/reducers/loading-reducer'; +import { chatMessageItemPropType } from 'lib/selectors/chat-selectors'; +import { messageKey } from 'lib/shared/message-utils'; +import { threadInChatList } from 'lib/shared/thread-utils'; +import threadWatcher from 'lib/shared/thread-watcher'; +import type { FetchMessageInfosPayload } from 'lib/types/message-types'; +import { + type ThreadInfo, + threadInfoPropType, + threadTypes, +} from 'lib/types/thread-types'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; +import _find from 'lodash/fp/find'; +import PropTypes from 'prop-types'; +import * as React from 'react'; +import { View, TouchableWithoutFeedback } from 'react-native'; +import { createSelector } from 'reselect'; -import { Message, type ChatMessageInfoItemWithHeight } from './message.react'; -import RelationshipPrompt from './relationship-prompt.react'; import ListLoadingIndicator from '../components/list-loading-indicator.react'; import { - useStyles, - type IndicatorStyle, - indicatorStylePropType, - useIndicatorStyle, -} from '../themes/colors'; + type KeyboardState, + keyboardStatePropType, + KeyboardContext, +} from '../keyboard/keyboard-state'; import { OverlayContext, type OverlayContextType, overlayContextPropType, } from '../navigation/overlay-context'; +import type { NavigationRoute } from '../navigation/route-names'; +import { useSelector } from '../redux/redux-utils'; import { - type KeyboardState, - keyboardStatePropType, - KeyboardContext, -} from '../keyboard/keyboard-state'; + useStyles, + type IndicatorStyle, + indicatorStylePropType, + useIndicatorStyle, +} from '../themes/colors'; +import type { VerticalBounds } from '../types/layout-types'; +import type { ViewToken } from '../types/react-native'; + import { ChatList } from './chat-list.react'; -import { useSelector } from '../redux/redux-utils'; +import type { ChatNavigationProp } from './chat.react'; +import type { ChatMessageItemWithHeight } from './message-list-container.react'; +import { + messageListRoutePropType, + messageListNavPropType, +} from './message-list-types'; +import { Message, type ChatMessageInfoItemWithHeight } from './message.react'; +import RelationshipPrompt from './relationship-prompt.react'; type BaseProps = {| +threadInfo: ThreadInfo, +messageListData: $ReadOnlyArray, +navigation: ChatNavigationProp<'MessageList'>, +route: NavigationRoute<'MessageList'>, |}; type Props = {| ...BaseProps, // Redux state +startReached: boolean, +styles: typeof unboundStyles, +indicatorStyle: IndicatorStyle, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +fetchMessagesBeforeCursor: ( threadID: string, beforeMessageID: string, ) => Promise, +fetchMostRecentMessages: ( threadID: string, ) => Promise, // withOverlayContext +overlayContext: ?OverlayContextType, // withKeyboardState +keyboardState: ?KeyboardState, |}; type State = {| +focusedMessageKey: ?string, +messageListVerticalBounds: ?VerticalBounds, +loadingFromScroll: boolean, |}; type PropsAndState = {| ...Props, ...State, |}; type FlatListExtraData = {| messageListVerticalBounds: ?VerticalBounds, focusedMessageKey: ?string, navigation: ChatNavigationProp<'MessageList'>, route: NavigationRoute<'MessageList'>, |}; class MessageList extends React.PureComponent { static propTypes = { threadInfo: threadInfoPropType.isRequired, messageListData: PropTypes.arrayOf(chatMessageItemPropType).isRequired, navigation: messageListNavPropType.isRequired, route: messageListRoutePropType.isRequired, startReached: PropTypes.bool.isRequired, styles: PropTypes.objectOf(PropTypes.object).isRequired, indicatorStyle: indicatorStylePropType.isRequired, dispatchActionPromise: PropTypes.func.isRequired, fetchMessagesBeforeCursor: PropTypes.func.isRequired, fetchMostRecentMessages: PropTypes.func.isRequired, overlayContext: overlayContextPropType, keyboardState: keyboardStatePropType, }; state: State = { focusedMessageKey: null, messageListVerticalBounds: null, loadingFromScroll: false, }; flatListContainer: ?React.ElementRef; flatListExtraDataSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.messageListVerticalBounds, (propsAndState: PropsAndState) => propsAndState.focusedMessageKey, (propsAndState: PropsAndState) => propsAndState.navigation, (propsAndState: PropsAndState) => propsAndState.route, ( messageListVerticalBounds: ?VerticalBounds, focusedMessageKey: ?string, navigation: ChatNavigationProp<'MessageList'>, route: NavigationRoute<'MessageList'>, ) => ({ messageListVerticalBounds, focusedMessageKey, navigation, route, }), ); get flatListExtraData(): FlatListExtraData { return this.flatListExtraDataSelector({ ...this.props, ...this.state }); } componentDidMount() { const { threadInfo } = this.props; if (!threadInChatList(threadInfo)) { threadWatcher.watchID(threadInfo.id); this.props.dispatchActionPromise( fetchMostRecentMessagesActionTypes, this.props.fetchMostRecentMessages(threadInfo.id), ); } } componentWillUnmount() { const { threadInfo } = this.props; if (!threadInChatList(threadInfo)) { threadWatcher.removeID(threadInfo.id); } } static getOverlayContext(props: Props) { const { overlayContext } = props; invariant(overlayContext, 'MessageList should have OverlayContext'); return overlayContext; } static scrollDisabled(props: Props) { const overlayContext = MessageList.getOverlayContext(props); return overlayContext.scrollBlockingModalStatus !== 'closed'; } static modalOpen(props: Props) { const overlayContext = MessageList.getOverlayContext(props); return overlayContext.scrollBlockingModalStatus === 'open'; } componentDidUpdate(prevProps: Props) { const oldThreadInfo = prevProps.threadInfo; const newThreadInfo = this.props.threadInfo; if (oldThreadInfo.id !== newThreadInfo.id) { if (!threadInChatList(oldThreadInfo)) { threadWatcher.removeID(oldThreadInfo.id); } if (!threadInChatList(newThreadInfo)) { threadWatcher.watchID(newThreadInfo.id); } } const newListData = this.props.messageListData; const oldListData = prevProps.messageListData; if ( this.state.loadingFromScroll && (newListData.length > oldListData.length || this.props.startReached) ) { this.setState({ loadingFromScroll: false }); } const modalIsOpen = MessageList.modalOpen(this.props); const modalWasOpen = MessageList.modalOpen(prevProps); if (!modalIsOpen && modalWasOpen) { this.setState({ focusedMessageKey: null }); } const scrollIsDisabled = MessageList.scrollDisabled(this.props); const scrollWasDisabled = MessageList.scrollDisabled(prevProps); if (!scrollWasDisabled && scrollIsDisabled) { this.props.navigation.setOptions({ gestureEnabled: false }); } else if (scrollWasDisabled && !scrollIsDisabled) { this.props.navigation.setOptions({ gestureEnabled: true }); } } dismissKeyboard = () => { const { keyboardState } = this.props; keyboardState && keyboardState.dismissKeyboard(); }; renderItem = (row: { item: ChatMessageItemWithHeight }) => { if (row.item.itemType === 'loader') { return ( ); } const messageInfoItem: ChatMessageInfoItemWithHeight = row.item; const { messageListVerticalBounds, focusedMessageKey, navigation, route, } = this.flatListExtraData; const focused = messageKey(messageInfoItem.messageInfo) === focusedMessageKey; return ( ); }; toggleMessageFocus = (inMessageKey: string) => { if (this.state.focusedMessageKey === inMessageKey) { this.setState({ focusedMessageKey: null }); } else { this.setState({ focusedMessageKey: inMessageKey }); } }; // Actually header, it's just that our FlatList is inverted ListFooterComponent = () => ; render() { const { messageListData, startReached } = this.props; const footer = startReached ? this.ListFooterComponent : undefined; let relationshipPrompt = null; if (this.props.threadInfo.type === threadTypes.PERSONAL) { relationshipPrompt = ( ); } return ( {relationshipPrompt} ); } flatListContainerRef = ( flatListContainer: ?React.ElementRef, ) => { this.flatListContainer = flatListContainer; }; onFlatListContainerLayout = () => { const { flatListContainer } = this; if (!flatListContainer) { return; } const { keyboardState } = this.props; if (!keyboardState || keyboardState.keyboardShowing) { return; } flatListContainer.measure((x, y, width, height, pageX, pageY) => { if ( height === null || height === undefined || pageY === null || pageY === undefined ) { return; } this.setState({ messageListVerticalBounds: { height, y: pageY } }); }); }; onViewableItemsChanged = (info: { viewableItems: ViewToken[], changed: ViewToken[], }) => { if (this.state.focusedMessageKey) { let focusedMessageVisible = false; for (let token of info.viewableItems) { if ( token.item.itemType === 'message' && messageKey(token.item.messageInfo) === this.state.focusedMessageKey ) { focusedMessageVisible = true; break; } } if (!focusedMessageVisible) { this.setState({ focusedMessageKey: null }); } } const loader = _find({ key: 'loader' })(info.viewableItems); if (!loader || this.state.loadingFromScroll) { return; } const oldestMessageServerID = this.oldestMessageServerID(); if (oldestMessageServerID) { this.setState({ loadingFromScroll: true }); const threadID = this.props.threadInfo.id; this.props.dispatchActionPromise( fetchMessagesBeforeCursorActionTypes, this.props.fetchMessagesBeforeCursor(threadID, oldestMessageServerID), ); } }; oldestMessageServerID(): ?string { const data = this.props.messageListData; for (let i = data.length - 1; i >= 0; i--) { if (data[i].itemType === 'message' && data[i].messageInfo.id) { return data[i].messageInfo.id; } } return null; } } const unboundStyles = { container: { backgroundColor: 'listBackground', flex: 1, }, header: { height: 12, }, listLoadingIndicator: { flex: 1, }, }; registerFetchKey(fetchMessagesBeforeCursorActionTypes); registerFetchKey(fetchMostRecentMessagesActionTypes); export default React.memo(function ConnectedMessageList( props: BaseProps, ) { const keyboardState = React.useContext(KeyboardContext); const overlayContext = React.useContext(OverlayContext); const threadID = props.threadInfo.id; const startReached = useSelector( (state) => !!( state.messageStore.threads[threadID] && state.messageStore.threads[threadID].startReached ), ); const styles = useStyles(unboundStyles); const indicatorStyle = useIndicatorStyle(); const dispatchActionPromise = useDispatchActionPromise(); const callFetchMessagesBeforeCursor = useServerCall( fetchMessagesBeforeCursor, ); const callFetchMostRecentMessages = useServerCall(fetchMostRecentMessages); return ( ); }); diff --git a/native/chat/message-preview.react.js b/native/chat/message-preview.react.js index 7f5aef2c5..dbcb76854 100644 --- a/native/chat/message-preview.react.js +++ b/native/chat/message-preview.react.js @@ -1,101 +1,99 @@ // @flow +import { messagePreviewText } from 'lib/shared/message-utils'; +import { threadIsGroupChat } from 'lib/shared/thread-utils'; +import { stringForUser } from 'lib/shared/user-utils'; import { type MessageInfo, messageInfoPropType, messageTypes, } from 'lib/types/message-types'; import { type ThreadInfo, threadInfoPropType } from 'lib/types/thread-types'; -import type { AppState } from '../redux/redux-setup'; - -import * as React from 'react'; -import { Text } from 'react-native'; -import PropTypes from 'prop-types'; - -import { messagePreviewText } from 'lib/shared/message-utils'; -import { threadIsGroupChat } from 'lib/shared/thread-utils'; -import { stringForUser } from 'lib/shared/user-utils'; import { connect } from 'lib/utils/redux-utils'; import { firstLine } from 'lib/utils/string-utils'; +import PropTypes from 'prop-types'; +import * as React from 'react'; +import { Text } from 'react-native'; -import { styleSelector } from '../themes/colors'; import { SingleLine } from '../components/single-line.react'; +import type { AppState } from '../redux/redux-setup'; +import { styleSelector } from '../themes/colors'; type Props = {| messageInfo: MessageInfo, threadInfo: ThreadInfo, // Redux state styles: typeof styles, |}; class MessagePreview extends React.PureComponent { static propTypes = { messageInfo: messageInfoPropType.isRequired, threadInfo: threadInfoPropType.isRequired, styles: PropTypes.objectOf(PropTypes.object).isRequired, }; render() { const messageInfo: MessageInfo = this.props.messageInfo; const unreadStyle = this.props.threadInfo.currentUser.unread ? this.props.styles.unread : null; if (messageInfo.type === messageTypes.TEXT) { let usernameText = null; if (threadIsGroupChat(this.props.threadInfo)) { const userString = stringForUser(messageInfo.creator); const username = `${userString}: `; usernameText = ( {username} ); } const firstMessageLine = firstLine(messageInfo.text); return ( {usernameText} {firstMessageLine} ); } else { const preview = messagePreviewText(messageInfo, this.props.threadInfo); return ( {preview} ); } } } const styles = { lastMessage: { color: 'listForegroundTertiaryLabel', flex: 1, fontSize: 16, paddingLeft: 10, }, preview: { color: 'listForegroundQuaternaryLabel', }, unread: { color: 'listForegroundLabel', }, username: { color: 'listForegroundQuaternaryLabel', }, }; const stylesSelector = styleSelector(styles); export default connect((state: AppState) => ({ styles: stylesSelector(state), }))(MessagePreview); diff --git a/native/chat/message-store-pruner.react.js b/native/chat/message-store-pruner.react.js index 04c8ade23..26dc1f153 100644 --- a/native/chat/message-store-pruner.react.js +++ b/native/chat/message-store-pruner.react.js @@ -1,68 +1,67 @@ // @flow +import { messageStorePruneActionType } from 'lib/actions/message-actions'; import * as React from 'react'; import { useDispatch } from 'react-redux'; -import { messageStorePruneActionType } from 'lib/actions/message-actions'; - +import { NavContext } from '../navigation/navigation-context'; +import { useSelector } from '../redux/redux-utils'; import { nextMessagePruneTimeSelector, pruneThreadIDsSelector, } from '../selectors/message-selectors'; -import { NavContext } from '../navigation/navigation-context'; -import { useSelector } from '../redux/redux-utils'; function MessageStorePruner() { const nextMessagePruneTime = useSelector(nextMessagePruneTimeSelector); const prevNextMessagePruneTimeRef = React.useRef(nextMessagePruneTime); const foreground = useSelector((state) => state.foreground); const frozen = useSelector((state) => state.frozen); const navContext = React.useContext(NavContext); const pruneThreadIDs = useSelector((state) => pruneThreadIDsSelector({ redux: state, navContext, }), ); const prunedRef = React.useRef(false); const dispatch = useDispatch(); React.useEffect(() => { if ( prunedRef.current && nextMessagePruneTime !== prevNextMessagePruneTimeRef.current ) { prunedRef.current = false; } prevNextMessagePruneTimeRef.current = nextMessagePruneTime; if (frozen || prunedRef.current) { return; } if (nextMessagePruneTime === null || nextMessagePruneTime === undefined) { return; } const timeUntilExpiration = nextMessagePruneTime - Date.now(); if (timeUntilExpiration > 0) { return; } const threadIDs = pruneThreadIDs(); if (threadIDs.length === 0) { return; } prunedRef.current = true; dispatch({ type: messageStorePruneActionType, payload: { threadIDs }, }); // We include foreground so this effect will be called on foreground }, [nextMessagePruneTime, frozen, foreground, pruneThreadIDs, dispatch]); return null; } export default MessageStorePruner; diff --git a/native/chat/message.react.js b/native/chat/message.react.js index bb92e7ed1..da26d42ea 100644 --- a/native/chat/message.react.js +++ b/native/chat/message.react.js @@ -1,183 +1,182 @@ // @flow -import type { ChatRobotextMessageInfoItemWithHeight } from './robotext-message.react'; -import type { ChatTextMessageInfoItemWithHeight } from './text-message.react'; -import type { ChatMultimediaMessageInfoItem } from './multimedia-message.react'; import { chatMessageItemPropType } from 'lib/selectors/chat-selectors'; +import { messageKey } from 'lib/shared/message-utils'; +import PropTypes from 'prop-types'; +import * as React from 'react'; import { - type VerticalBounds, - verticalBoundsPropType, -} from '../types/layout-types'; -import { - messageListRoutePropType, - messageListNavPropType, -} from './message-list-types'; + LayoutAnimation, + TouchableWithoutFeedback, + PixelRatio, +} from 'react-native'; + import { type KeyboardState, keyboardStatePropType, KeyboardContext, } from '../keyboard/keyboard-state'; -import type { ChatNavigationProp } from './chat.react'; import type { NavigationRoute } from '../navigation/route-names'; -import type { LayoutEvent } from '../types/react-native'; - -import * as React from 'react'; import { - LayoutAnimation, - TouchableWithoutFeedback, - PixelRatio, -} from 'react-native'; -import PropTypes from 'prop-types'; - -import { messageKey } from 'lib/shared/message-utils'; + type VerticalBounds, + verticalBoundsPropType, +} from '../types/layout-types'; +import type { LayoutEvent } from '../types/react-native'; -import { TextMessage, textMessageItemHeight } from './text-message.react'; +import type { ChatNavigationProp } from './chat.react'; import { - RobotextMessage, - robotextMessageItemHeight, -} from './robotext-message.react'; + messageListRoutePropType, + messageListNavPropType, +} from './message-list-types'; +import type { ChatMultimediaMessageInfoItem } from './multimedia-message.react'; import { MultimediaMessage, multimediaMessageItemHeight, } from './multimedia-message.react'; +import type { ChatRobotextMessageInfoItemWithHeight } from './robotext-message.react'; +import { + RobotextMessage, + robotextMessageItemHeight, +} from './robotext-message.react'; +import type { ChatTextMessageInfoItemWithHeight } from './text-message.react'; +import { TextMessage, textMessageItemHeight } from './text-message.react'; import { timestampHeight } from './timestamp.react'; export type ChatMessageInfoItemWithHeight = | ChatRobotextMessageInfoItemWithHeight | ChatTextMessageInfoItemWithHeight | ChatMultimediaMessageInfoItem; function messageItemHeight(item: ChatMessageInfoItemWithHeight) { let height = 0; if (item.messageShapeType === 'text') { height += textMessageItemHeight(item); } else if (item.messageShapeType === 'multimedia') { height += multimediaMessageItemHeight(item); } else { height += robotextMessageItemHeight(item); } if (item.startsConversation) { height += timestampHeight; } return height; } type BaseProps = {| +item: ChatMessageInfoItemWithHeight, +focused: boolean, +navigation: ChatNavigationProp<'MessageList'>, +route: NavigationRoute<'MessageList'>, +toggleFocus: (messageKey: string) => void, +verticalBounds: ?VerticalBounds, |}; type Props = {| ...BaseProps, // withKeyboardState +keyboardState: ?KeyboardState, |}; class Message extends React.PureComponent { static propTypes = { item: chatMessageItemPropType.isRequired, focused: PropTypes.bool.isRequired, navigation: messageListNavPropType.isRequired, route: messageListRoutePropType.isRequired, toggleFocus: PropTypes.func.isRequired, verticalBounds: verticalBoundsPropType, keyboardState: keyboardStatePropType, }; componentDidUpdate(prevProps: Props) { if ( (prevProps.focused || prevProps.item.startsConversation) !== (this.props.focused || this.props.item.startsConversation) ) { LayoutAnimation.easeInEaseOut(); } } render() { let message; if (this.props.item.messageShapeType === 'text') { message = ( ); } else if (this.props.item.messageShapeType === 'multimedia') { message = ( ); } else { message = ( ); } const onLayout = __DEV__ ? this.onLayout : undefined; return ( {message} ); } onLayout = (event: LayoutEvent) => { if (this.props.focused) { return; } const measuredHeight = event.nativeEvent.layout.height; const expectedHeight = messageItemHeight(this.props.item); const pixelRatio = 1 / PixelRatio.get(); const distance = Math.abs(measuredHeight - expectedHeight); if (distance < pixelRatio) { return; } const approxMeasuredHeight = Math.round(measuredHeight * 100) / 100; const approxExpectedHeight = Math.round(expectedHeight * 100) / 100; console.log( `Message height for ${this.props.item.messageShapeType} ` + `${messageKey(this.props.item.messageInfo)} was expected to be ` + `${approxExpectedHeight} but is actually ${approxMeasuredHeight}. ` + "This means MessageList's FlatList isn't getting the right item " + 'height for some of its nodes, which is guaranteed to cause glitchy ' + 'behavior. Please investigate!!', ); }; dismissKeyboard = () => { const { keyboardState } = this.props; keyboardState && keyboardState.dismissKeyboard(); }; } const ConnectedMessage = React.memo(function ConnectedMessage( props: BaseProps, ) { const keyboardState = React.useContext(KeyboardContext); return ; }); export { ConnectedMessage as Message, messageItemHeight }; diff --git a/native/chat/multimedia-message-multimedia.react.js b/native/chat/multimedia-message-multimedia.react.js index 0f297b02d..d42bc0476 100644 --- a/native/chat/multimedia-message-multimedia.react.js +++ b/native/chat/multimedia-message-multimedia.react.js @@ -1,343 +1,342 @@ // @flow +import invariant from 'invariant'; +import { chatMessageItemPropType } from 'lib/selectors/chat-selectors'; +import { messageKey } from 'lib/shared/message-utils'; import { type MediaInfo, mediaInfoPropType } from 'lib/types/media-types'; -import type { ChatMultimediaMessageInfoItem } from './multimedia-message.react'; -import type { ViewStyle } from '../types/styles'; -import { - MultimediaModalRouteName, - MultimediaTooltipModalRouteName, -} from '../navigation/route-names'; -import { - type VerticalBounds, - verticalBoundsPropType, -} from '../types/layout-types'; -import { - type PendingMultimediaUpload, - pendingMultimediaUploadPropType, -} from '../input/input-state'; -import { - messageListRoutePropType, - messageListNavPropType, -} from './message-list-types'; -import type { ChatNavigationProp } from './chat.react'; -import type { NavigationRoute } from '../navigation/route-names'; - -import * as React from 'react'; import PropTypes from 'prop-types'; +import * as React from 'react'; import { View, StyleSheet } from 'react-native'; import Animated from 'react-native-reanimated'; -import invariant from 'invariant'; -import { messageKey } from 'lib/shared/message-utils'; -import { chatMessageItemPropType } from 'lib/selectors/chat-selectors'; - -import InlineMultimedia from './inline-multimedia.react'; -import { multimediaTooltipHeight } from './multimedia-tooltip-modal.react'; -import { type Colors, colorsPropType, useColors } from '../themes/colors'; +import { + type PendingMultimediaUpload, + pendingMultimediaUploadPropType, +} from '../input/input-state'; import { type KeyboardState, keyboardStatePropType, KeyboardContext, } from '../keyboard/keyboard-state'; import { OverlayContext, type OverlayContextType, overlayContextPropType, } from '../navigation/overlay-context'; +import type { NavigationRoute } from '../navigation/route-names'; +import { + MultimediaModalRouteName, + MultimediaTooltipModalRouteName, +} from '../navigation/route-names'; +import { type Colors, colorsPropType, useColors } from '../themes/colors'; +import { + type VerticalBounds, + verticalBoundsPropType, +} from '../types/layout-types'; +import type { ViewStyle } from '../types/styles'; + +import type { ChatNavigationProp } from './chat.react'; +import InlineMultimedia from './inline-multimedia.react'; +import { + messageListRoutePropType, + messageListNavPropType, +} from './message-list-types'; +import type { ChatMultimediaMessageInfoItem } from './multimedia-message.react'; +import { multimediaTooltipHeight } from './multimedia-tooltip-modal.react'; /* eslint-disable import/no-named-as-default-member */ const { Value, sub, interpolate, Extrapolate } = Animated; /* eslint-enable import/no-named-as-default-member */ type BaseProps = {| +mediaInfo: MediaInfo, +item: ChatMultimediaMessageInfoItem, +navigation: ChatNavigationProp<'MessageList'>, +route: NavigationRoute<'MessageList'>, +verticalBounds: ?VerticalBounds, +verticalOffset: number, +style: ViewStyle, +postInProgress: boolean, +pendingUpload: ?PendingMultimediaUpload, +messageFocused: boolean, +toggleMessageFocus: (messageKey: string) => void, |}; type Props = {| ...BaseProps, // Redux state +colors: Colors, // withKeyboardState +keyboardState: ?KeyboardState, // withOverlayContext +overlayContext: ?OverlayContextType, |}; type State = {| +opacity: number | Value, |}; class MultimediaMessageMultimedia extends React.PureComponent { static propTypes = { mediaInfo: mediaInfoPropType.isRequired, item: chatMessageItemPropType.isRequired, navigation: messageListNavPropType.isRequired, route: messageListRoutePropType.isRequired, verticalBounds: verticalBoundsPropType, verticalOffset: PropTypes.number.isRequired, postInProgress: PropTypes.bool.isRequired, pendingUpload: pendingMultimediaUploadPropType, messageFocused: PropTypes.bool.isRequired, toggleMessageFocus: PropTypes.func.isRequired, colors: colorsPropType.isRequired, keyboardState: keyboardStatePropType, overlayContext: overlayContextPropType, }; view: ?React.ElementRef; clickable = true; constructor(props: Props) { super(props); this.state = { opacity: this.getOpacity(), }; } static getStableKey(props: Props) { const { item, mediaInfo } = props; return `multimedia|${messageKey(item.messageInfo)}|${mediaInfo.index}`; } static getOverlayContext(props: Props) { const { overlayContext } = props; invariant( overlayContext, 'MultimediaMessageMultimedia should have OverlayContext', ); return overlayContext; } static getModalOverlayPosition(props: Props) { const overlayContext = MultimediaMessageMultimedia.getOverlayContext(props); const { visibleOverlays } = overlayContext; for (let overlay of visibleOverlays) { if ( overlay.routeName === MultimediaModalRouteName && overlay.presentedFrom === props.route.key && overlay.routeKey === MultimediaMessageMultimedia.getStableKey(props) ) { return overlay.position; } } return undefined; } getOpacity() { const overlayPosition = MultimediaMessageMultimedia.getModalOverlayPosition( this.props, ); if (!overlayPosition) { return 1; } return sub( 1, interpolate(overlayPosition, { inputRange: [0.1, 0.11], outputRange: [0, 1], extrapolate: Extrapolate.CLAMP, }), ); } componentDidUpdate(prevProps: Props) { const overlayPosition = MultimediaMessageMultimedia.getModalOverlayPosition( this.props, ); const prevOverlayPosition = MultimediaMessageMultimedia.getModalOverlayPosition( prevProps, ); if (overlayPosition !== prevOverlayPosition) { this.setState({ opacity: this.getOpacity() }); } const scrollIsDisabled = MultimediaMessageMultimedia.getOverlayContext(this.props) .scrollBlockingModalStatus !== 'closed'; const scrollWasDisabled = MultimediaMessageMultimedia.getOverlayContext(prevProps) .scrollBlockingModalStatus !== 'closed'; if (!scrollIsDisabled && scrollWasDisabled) { this.clickable = true; } } render() { const { opacity } = this.state; const wrapperStyles = [styles.container, { opacity }, this.props.style]; const { mediaInfo, pendingUpload, postInProgress } = this.props; return ( ); } onLayout = () => {}; viewRef = (view: ?React.ElementRef) => { this.view = view; }; onPress = () => { if (this.dismissKeyboardIfShowing()) { return; } const { view, props: { verticalBounds }, } = this; if (!view || !verticalBounds) { return; } if (!this.clickable) { return; } this.clickable = false; const overlayContext = MultimediaMessageMultimedia.getOverlayContext( this.props, ); overlayContext.setScrollBlockingModalStatus('open'); const { mediaInfo, item } = this.props; view.measure((x, y, width, height, pageX, pageY) => { const coordinates = { x: pageX, y: pageY, width, height }; this.props.navigation.navigate({ name: MultimediaModalRouteName, key: MultimediaMessageMultimedia.getStableKey(this.props), params: { presentedFrom: this.props.route.key, mediaInfo, item, initialCoordinates: coordinates, verticalBounds, }, }); }); }; onLongPress = () => { if (this.dismissKeyboardIfShowing()) { return; } const { view, props: { verticalBounds }, } = this; if (!view || !verticalBounds) { return; } if (!this.clickable) { return; } this.clickable = false; const { messageFocused, toggleMessageFocus, item, mediaInfo, verticalOffset, } = this.props; if (!messageFocused) { toggleMessageFocus(messageKey(item.messageInfo)); } const overlayContext = MultimediaMessageMultimedia.getOverlayContext( this.props, ); overlayContext.setScrollBlockingModalStatus('open'); view.measure((x, y, width, height, pageX, pageY) => { const coordinates = { x: pageX, y: pageY, width, height }; const multimediaTop = pageY; const multimediaBottom = pageY + height; const boundsTop = verticalBounds.y; const boundsBottom = verticalBounds.y + verticalBounds.height; const belowMargin = 20; const belowSpace = multimediaTooltipHeight + belowMargin; const { isViewer } = item.messageInfo.creator; const directlyAboveMargin = isViewer ? 30 : 50; const aboveMargin = verticalOffset === 0 ? directlyAboveMargin : 20; const aboveSpace = multimediaTooltipHeight + aboveMargin; let location = 'below', margin = belowMargin; if ( multimediaBottom + belowSpace > boundsBottom && multimediaTop - aboveSpace > boundsTop ) { location = 'above'; margin = aboveMargin; } this.props.navigation.navigate({ name: MultimediaTooltipModalRouteName, params: { presentedFrom: this.props.route.key, mediaInfo, item, initialCoordinates: coordinates, verticalOffset, verticalBounds, location, margin, }, }); }); }; dismissKeyboardIfShowing = () => { const { keyboardState } = this.props; return !!(keyboardState && keyboardState.dismissKeyboardIfShowing()); }; } const styles = StyleSheet.create({ container: { flex: 1, overflow: 'hidden', }, expand: { flex: 1, }, }); export default React.memo( function ConnectedMultimediaMessageMultimedia(props: BaseProps) { const colors = useColors(); const keyboardState = React.useContext(KeyboardContext); const overlayContext = React.useContext(OverlayContext); return ( ); }, ); diff --git a/native/chat/multimedia-message.react.js b/native/chat/multimedia-message.react.js index c307b74e7..2b66724a9 100644 --- a/native/chat/multimedia-message.react.js +++ b/native/chat/multimedia-message.react.js @@ -1,307 +1,307 @@ // @flow +import invariant from 'invariant'; import { chatMessageItemPropType } from 'lib/selectors/chat-selectors'; +import type { Media, Corners } from 'lib/types/media-types'; import type { MultimediaMessageInfo, LocalMessageInfo, } from 'lib/types/message-types'; -import type { Media, Corners } from 'lib/types/media-types'; -import type { ViewStyle } from '../types/styles'; import type { ThreadInfo } from 'lib/types/thread-types'; +import PropTypes from 'prop-types'; +import * as React from 'react'; +import { StyleSheet, View } from 'react-native'; + +import type { MessagePendingUploads } from '../input/input-state'; +import type { NavigationRoute } from '../navigation/route-names'; import { type VerticalBounds, verticalBoundsPropType, } from '../types/layout-types'; -import type { MessagePendingUploads } from '../input/input-state'; +import type { ViewStyle } from '../types/styles'; + +import type { ChatNavigationProp } from './chat.react'; +import { ComposedMessage, clusterEndHeight } from './composed-message.react'; +import { failedSendHeight } from './failed-send.react'; +import { authorNameHeight } from './message-header.react'; import { messageListRoutePropType, messageListNavPropType, } from './message-list-types'; -import type { ChatNavigationProp } from './chat.react'; -import type { NavigationRoute } from '../navigation/route-names'; - -import * as React from 'react'; -import { StyleSheet, View } from 'react-native'; -import PropTypes from 'prop-types'; -import invariant from 'invariant'; - -import { ComposedMessage, clusterEndHeight } from './composed-message.react'; import MultimediaMessageMultimedia from './multimedia-message-multimedia.react'; +import sendFailed from './multimedia-message-send-failed'; import { allCorners, filterCorners, getRoundedContainerStyle, } from './rounded-corners'; -import { authorNameHeight } from './message-header.react'; -import { failedSendHeight } from './failed-send.react'; -import sendFailed from './multimedia-message-send-failed'; type ContentSizes = {| imageHeight: number, contentHeight: number, contentWidth: number, |}; export type ChatMultimediaMessageInfoItem = {| ...ContentSizes, itemType: 'message', messageShapeType: 'multimedia', messageInfo: MultimediaMessageInfo, localMessageInfo: ?LocalMessageInfo, threadInfo: ThreadInfo, startsConversation: boolean, startsCluster: boolean, endsCluster: boolean, pendingUploads: ?MessagePendingUploads, |}; function getMediaPerRow(mediaCount: number) { if (mediaCount === 0) { return 0; // ??? } else if (mediaCount === 1) { return 1; } else if (mediaCount === 2) { return 2; } else if (mediaCount === 3) { return 3; } else if (mediaCount === 4) { return 2; } else { return 3; } } // Called by MessageListContainer // The results are merged into ChatMultimediaMessageInfoItem function multimediaMessageContentSizes( messageInfo: MultimediaMessageInfo, composedMessageMaxWidth: number, ): ContentSizes { invariant(messageInfo.media.length > 0, 'should have media'); if (messageInfo.media.length === 1) { const [media] = messageInfo.media; const { height, width } = media.dimensions; let imageHeight = height; if (width > composedMessageMaxWidth) { imageHeight = (height * composedMessageMaxWidth) / width; } if (imageHeight < 50) { imageHeight = 50; } let contentWidth = height ? (width * imageHeight) / height : 0; if (contentWidth > composedMessageMaxWidth) { contentWidth = composedMessageMaxWidth; } return { imageHeight, contentHeight: imageHeight, contentWidth }; } const contentWidth = composedMessageMaxWidth; const mediaPerRow = getMediaPerRow(messageInfo.media.length); const marginSpace = spaceBetweenImages * (mediaPerRow - 1); const imageHeight = (contentWidth - marginSpace) / mediaPerRow; const numRows = Math.ceil(messageInfo.media.length / mediaPerRow); const contentHeight = numRows * imageHeight + (numRows - 1) * spaceBetweenImages; return { imageHeight, contentHeight, contentWidth }; } // Called by Message // Given a ChatMultimediaMessageInfoItem, determines exact height of row function multimediaMessageItemHeight(item: ChatMultimediaMessageInfoItem) { const { messageInfo, contentHeight, startsCluster, endsCluster } = item; const { creator } = messageInfo; const { isViewer } = creator; let height = 5 + contentHeight; // 5 from marginBottom in ComposedMessage if (!isViewer && startsCluster) { height += authorNameHeight; } if (endsCluster) { height += clusterEndHeight; } if (sendFailed(item)) { height += failedSendHeight; } return height; } const borderRadius = 16; type Props = {| ...React.ElementConfig, item: ChatMultimediaMessageInfoItem, navigation: ChatNavigationProp<'MessageList'>, route: NavigationRoute<'MessageList'>, focused: boolean, toggleFocus: (messageKey: string) => void, verticalBounds: ?VerticalBounds, |}; class MultimediaMessage extends React.PureComponent { static propTypes = { item: chatMessageItemPropType.isRequired, navigation: messageListNavPropType.isRequired, route: messageListRoutePropType.isRequired, focused: PropTypes.bool.isRequired, toggleFocus: PropTypes.func.isRequired, verticalBounds: verticalBoundsPropType, }; render() { const { item, navigation, route, focused, toggleFocus, verticalBounds, ...viewProps } = this.props; const containerStyle = { height: item.contentHeight, width: item.contentWidth, }; return ( {this.renderContent()} ); } renderContent(): React.Node { const { messageInfo, imageHeight } = this.props.item; invariant(messageInfo.media.length > 0, 'should have media'); if (messageInfo.media.length === 1) { return this.renderImage(messageInfo.media[0], 0, 0, allCorners); } const mediaPerRow = getMediaPerRow(messageInfo.media.length); const rowHeight = imageHeight + spaceBetweenImages; const rows = []; for ( let i = 0, verticalOffset = 0; i < messageInfo.media.length; i += mediaPerRow, verticalOffset += rowHeight ) { const rowMedia = []; for (let j = i; j < i + mediaPerRow; j++) { rowMedia.push(messageInfo.media[j]); } const firstRow = i === 0; const lastRow = i + mediaPerRow >= messageInfo.media.length; const row = []; let j = 0; for (; j < rowMedia.length; j++) { const media = rowMedia[j]; const firstInRow = j === 0; const lastInRow = j + 1 === rowMedia.length; const inLastColumn = j + 1 === mediaPerRow; const corners = { topLeft: firstRow && firstInRow, topRight: firstRow && inLastColumn, bottomLeft: lastRow && firstInRow, bottomRight: lastRow && inLastColumn, }; const style = lastInRow ? null : styles.imageBeforeImage; row.push( this.renderImage(media, i + j, verticalOffset, corners, style), ); } for (; j < mediaPerRow; j++) { const key = `filler${j}`; const style = j + 1 < mediaPerRow ? [styles.filler, styles.imageBeforeImage] : styles.filler; row.push(); } const rowStyle = lastRow ? styles.row : [styles.row, styles.rowAboveRow]; rows.push( {row} , ); } return {rows}; } renderImage( media: Media, index: number, verticalOffset: number, corners: Corners, style?: ViewStyle, ): React.Node { const filteredCorners = filterCorners(corners, this.props.item); const roundedStyle = getRoundedContainerStyle( filteredCorners, borderRadius, ); const { pendingUploads } = this.props.item; const mediaInfo = { ...media, corners: filteredCorners, index, }; const pendingUpload = pendingUploads && pendingUploads[media.id]; return ( ); } } const spaceBetweenImages = 4; const styles = StyleSheet.create({ filler: { flex: 1, }, grid: { flex: 1, justifyContent: 'space-between', }, imageBeforeImage: { marginRight: spaceBetweenImages, }, row: { flex: 1, flexDirection: 'row', justifyContent: 'space-between', }, rowAboveRow: { marginBottom: spaceBetweenImages, }, }); export { borderRadius as multimediaMessageBorderRadius, MultimediaMessage, multimediaMessageContentSizes, multimediaMessageItemHeight, sendFailed as multimediaMessageSendFailed, }; diff --git a/native/chat/multimedia-tooltip-button.react.js b/native/chat/multimedia-tooltip-button.react.js index 40e0c7b28..313238faa 100644 --- a/native/chat/multimedia-tooltip-button.react.js +++ b/native/chat/multimedia-tooltip-button.react.js @@ -1,140 +1,139 @@ // @flow +import { chatMessageItemPropType } from 'lib/selectors/chat-selectors'; +import { messageID } from 'lib/shared/message-utils'; import { mediaInfoPropType } from 'lib/types/media-types'; -import { - verticalBoundsPropType, - layoutCoordinatesPropType, -} from '../types/layout-types'; -import type { AppNavigationProp } from '../navigation/app-navigator.react'; -import type { TooltipRoute } from '../navigation/tooltip.react'; - -import * as React from 'react'; -import Animated from 'react-native-reanimated'; import PropTypes from 'prop-types'; +import * as React from 'react'; import { View, StyleSheet } from 'react-native'; - -import { chatMessageItemPropType } from 'lib/selectors/chat-selectors'; -import { messageID } from 'lib/shared/message-utils'; +import Animated from 'react-native-reanimated'; import { type InputState, inputStatePropType, InputStateContext, } from '../input/input-state'; +import type { AppNavigationProp } from '../navigation/app-navigator.react'; +import type { TooltipRoute } from '../navigation/tooltip.react'; +import { useSelector } from '../redux/redux-utils'; +import { + verticalBoundsPropType, + layoutCoordinatesPropType, +} from '../types/layout-types'; + import InlineMultimedia from './inline-multimedia.react'; +import { MessageHeader } from './message-header.react'; import { multimediaMessageBorderRadius } from './multimedia-message.react'; import { getRoundedContainerStyle } from './rounded-corners'; -import { MessageHeader } from './message-header.react'; -import { useSelector } from '../redux/redux-utils'; /* eslint-disable import/no-named-as-default-member */ const { Value } = Animated; /* eslint-enable import/no-named-as-default-member */ type BaseProps = {| +navigation: AppNavigationProp<'MultimediaTooltipModal'>, +route: TooltipRoute<'MultimediaTooltipModal'>, +progress: Value, |}; type Props = {| ...BaseProps, // Redux state +windowWidth: number, // withInputState +inputState: ?InputState, |}; class MultimediaTooltipButton extends React.PureComponent { static propTypes = { navigation: PropTypes.shape({ goBackOnce: PropTypes.func.isRequired, }).isRequired, route: PropTypes.shape({ params: PropTypes.shape({ initialCoordinates: layoutCoordinatesPropType.isRequired, verticalBounds: verticalBoundsPropType.isRequired, location: PropTypes.oneOf(['above', 'below']), margin: PropTypes.number, item: chatMessageItemPropType.isRequired, mediaInfo: mediaInfoPropType.isRequired, verticalOffset: PropTypes.number.isRequired, }).isRequired, }).isRequired, progress: PropTypes.object.isRequired, windowWidth: PropTypes.number.isRequired, inputState: inputStatePropType, }; get headerStyle() { const { initialCoordinates, verticalOffset } = this.props.route.params; const bottom = initialCoordinates.height + verticalOffset; return { opacity: this.props.progress, position: 'absolute', left: -initialCoordinates.x, width: this.props.windowWidth, bottom, }; } render() { const { inputState } = this.props; const { mediaInfo, item } = this.props.route.params; const { id: mediaID } = mediaInfo; const ourMessageID = messageID(item.messageInfo); const pendingUploads = inputState && inputState.pendingUploads && inputState.pendingUploads[ourMessageID]; const pendingUpload = pendingUploads && pendingUploads[mediaID]; const postInProgress = !!pendingUploads; const roundedStyle = getRoundedContainerStyle( mediaInfo.corners, multimediaMessageBorderRadius, ); return ( ); } onPress = () => { this.props.navigation.goBackOnce(); }; } const styles = StyleSheet.create({ media: { flex: 1, overflow: 'hidden', }, }); export default React.memo(function ConnectedMultimediaTooltipButton( props: BaseProps, ) { const windowWidth = useSelector((state) => state.dimensions.width); const inputState = React.useContext(InputStateContext); return ( ); }); diff --git a/native/chat/multimedia-tooltip-modal.react.js b/native/chat/multimedia-tooltip-modal.react.js index 17fdac644..ef43134ee 100644 --- a/native/chat/multimedia-tooltip-modal.react.js +++ b/native/chat/multimedia-tooltip-modal.react.js @@ -1,40 +1,41 @@ // @flow import type { MediaInfo } from 'lib/types/media-types'; -import type { ChatMultimediaMessageInfoItem } from './multimedia-message.react'; +import { intentionalSaveMedia } from '../media/save-media'; import { createTooltip, tooltipHeight, type TooltipParams, type TooltipRoute, } from '../navigation/tooltip.react'; + +import type { ChatMultimediaMessageInfoItem } from './multimedia-message.react'; import MultimediaTooltipButton from './multimedia-tooltip-button.react'; -import { intentionalSaveMedia } from '../media/save-media'; export type MultimediaTooltipModalParams = TooltipParams<{| +item: ChatMultimediaMessageInfoItem, +mediaInfo: MediaInfo, +verticalOffset: number, |}>; function onPressSave(route: TooltipRoute<'MultimediaTooltipModal'>) { const { mediaInfo, item } = route.params; const { id: uploadID, uri } = mediaInfo; const { id: messageServerID, localID: messageLocalID } = item.messageInfo; const ids = { uploadID, messageServerID, messageLocalID }; return intentionalSaveMedia(uri, ids); } const spec = { entries: [{ id: 'save', text: 'Save', onPress: onPressSave }], }; const MultimediaTooltipModal = createTooltip<'MultimediaTooltipModal'>( MultimediaTooltipButton, spec, ); const multimediaTooltipHeight = tooltipHeight(spec.entries.length); export { MultimediaTooltipModal, multimediaTooltipHeight }; diff --git a/native/chat/new-messages-pill.react.js b/native/chat/new-messages-pill.react.js index 2d46989ba..9fa757014 100644 --- a/native/chat/new-messages-pill.react.js +++ b/native/chat/new-messages-pill.react.js @@ -1,74 +1,73 @@ // @flow -import type { ViewStyle } from '../types/styles'; - import * as React from 'react'; import { TouchableOpacity, View, Text, Platform, Animated } from 'react-native'; import Icon from 'react-native-vector-icons/FontAwesome'; import { useStyles } from '../themes/colors'; +import type { ViewStyle } from '../types/styles'; type Props = {| onPress: () => mixed, newMessageCount: number, containerStyle?: ViewStyle, style?: ViewStyle, ...React.ElementConfig, |}; function NewMessagesPill(props: Props) { const { onPress, newMessageCount, containerStyle, style, ...containerProps } = props; const styles = useStyles(unboundStyles); return ( {newMessageCount} ); } const unboundStyles = { countBubble: { alignItems: 'center', backgroundColor: 'greenButton', borderRadius: 25, height: 25, justifyContent: 'center', paddingBottom: Platform.OS === 'android' ? 2 : 0, paddingLeft: 1, position: 'absolute', right: -8, top: -8, width: 25, }, countText: { color: 'white', textAlign: 'center', }, button: { backgroundColor: 'floatingButtonBackground', borderColor: 'floatingButtonLabel', borderRadius: 30, borderWidth: 4, paddingHorizontal: 12, paddingVertical: 6, }, icon: { color: 'floatingButtonLabel', fontSize: 32, fontWeight: 'bold', }, }; export default NewMessagesPill; diff --git a/native/chat/relationship-prompt.react.js b/native/chat/relationship-prompt.react.js index 465f428b2..9bc66e5f9 100644 --- a/native/chat/relationship-prompt.react.js +++ b/native/chat/relationship-prompt.react.js @@ -1,213 +1,211 @@ // @flow +import invariant from 'invariant'; +import { + updateRelationships as serverUpdateRelationships, + updateRelationshipsActionTypes, +} from 'lib/actions/relationship-actions'; import type { RelationshipAction } from 'lib/types/relationship-types'; import { relationshipActions, userRelationshipStatus, } from 'lib/types/relationship-types'; -import type { UserInfo } from 'lib/types/user-types'; import type { ThreadInfo } from 'lib/types/thread-types'; - -import * as React from 'react'; -import { Alert, Text, View } from 'react-native'; -import invariant from 'invariant'; - +import type { UserInfo } from 'lib/types/user-types'; import { useDispatchActionPromise, useServerCall, } from 'lib/utils/action-utils'; -import { - updateRelationships as serverUpdateRelationships, - updateRelationshipsActionTypes, -} from 'lib/actions/relationship-actions'; +import * as React from 'react'; +import { Alert, Text, View } from 'react-native'; import Button from '../components/button.react'; -import { useStyles } from '../themes/colors'; import { useSelector } from '../redux/redux-utils'; +import { useStyles } from '../themes/colors'; type Props = {| +pendingPersonalThreadUserInfo: ?UserInfo, +threadInfo: ThreadInfo, |}; export default React.memo(function RelationshipPrompt({ pendingPersonalThreadUserInfo, threadInfo, }: Props) { // We're fetching the info from state because we need the most recent // relationship status. Additionally, member info does not contain info // about relationship. const otherUserInfo = useSelector((state) => { const currentUserID = state.currentUserInfo?.id; const otherUserID = threadInfo.members .map((member) => member.id) .find((id) => id !== currentUserID) ?? pendingPersonalThreadUserInfo?.id; const { userInfos } = state.userStore; return otherUserID && userInfos[otherUserID] ? userInfos[otherUserID] : pendingPersonalThreadUserInfo; }); const callUpdateRelationships = useServerCall(serverUpdateRelationships); const updateRelationship = React.useCallback( async (action: RelationshipAction) => { try { invariant(otherUserInfo, 'Other user info should be present'); return await callUpdateRelationships({ action, userIDs: [otherUserInfo.id], }); } catch (e) { Alert.alert('Unknown error', 'Uhh... try again?', [{ text: 'OK' }]); throw e; } }, [callUpdateRelationships, otherUserInfo], ); const dispatchActionPromise = useDispatchActionPromise(); const onButtonPress = React.useCallback( (action: RelationshipAction) => { invariant( otherUserInfo, 'User info should be present when a button is clicked', ); dispatchActionPromise( updateRelationshipsActionTypes, updateRelationship(action), ); }, [dispatchActionPromise, otherUserInfo, updateRelationship], ); const blockUser = React.useCallback( () => onButtonPress(relationshipActions.BLOCK), [onButtonPress], ); const unblockUser = React.useCallback( () => onButtonPress(relationshipActions.UNBLOCK), [onButtonPress], ); const friendUser = React.useCallback( () => onButtonPress(relationshipActions.FRIEND), [onButtonPress], ); const unfriendUser = React.useCallback( () => onButtonPress(relationshipActions.UNFRIEND), [onButtonPress], ); const styles = useStyles(unboundStyles); if ( !otherUserInfo || !otherUserInfo.username || otherUserInfo.relationshipStatus === userRelationshipStatus.FRIEND ) { return null; } if ( otherUserInfo.relationshipStatus === userRelationshipStatus.BLOCKED_VIEWER ) { return ( ); } if ( otherUserInfo.relationshipStatus === userRelationshipStatus.BOTH_BLOCKED || otherUserInfo.relationshipStatus === userRelationshipStatus.BLOCKED_BY_VIEWER ) { return ( ); } if ( otherUserInfo.relationshipStatus === userRelationshipStatus.REQUEST_RECEIVED ) { return ( ); } if ( otherUserInfo.relationshipStatus === userRelationshipStatus.REQUEST_SENT ) { return ( ); } return ( ); }); const unboundStyles = { container: { paddingVertical: 10, paddingHorizontal: 5, backgroundColor: 'panelBackground', flexDirection: 'row', }, button: { padding: 10, borderRadius: 5, flex: 1, marginHorizontal: 5, }, greenButton: { backgroundColor: 'greenButton', }, redButton: { backgroundColor: 'redButton', }, buttonText: { fontSize: 16, textAlign: 'center', }, }; diff --git a/native/chat/robotext-message.react.js b/native/chat/robotext-message.react.js index 508e8a984..dc133dd0c 100644 --- a/native/chat/robotext-message.react.js +++ b/native/chat/robotext-message.react.js @@ -1,209 +1,208 @@ // @flow -import type { ThreadInfo } from 'lib/types/thread-types'; -import type { RobotextMessageInfo } from 'lib/types/message-types'; -import { KeyboardContext } from '../keyboard/keyboard-state'; -import type { ChatNavigationProp } from './chat.react'; - -import * as React from 'react'; -import { Text, TouchableWithoutFeedback, View } from 'react-native'; import invariant from 'invariant'; - +import { threadInfoSelector } from 'lib/selectors/thread-selectors'; import { messageKey, splitRobotext, parseRobotextEntity, robotextToRawString, } from 'lib/shared/message-utils'; -import { threadInfoSelector } from 'lib/selectors/thread-selectors'; +import type { RobotextMessageInfo } from 'lib/types/message-types'; +import type { ThreadInfo } from 'lib/types/thread-types'; +import * as React from 'react'; +import { Text, TouchableWithoutFeedback, View } from 'react-native'; -import { MessageListRouteName } from '../navigation/route-names'; -import { Timestamp } from './timestamp.react'; -import { useStyles } from '../themes/colors'; +import { KeyboardContext } from '../keyboard/keyboard-state'; import Markdown from '../markdown/markdown.react'; import { inlineMarkdownRules } from '../markdown/rules.react'; +import { MessageListRouteName } from '../navigation/route-names'; import { useSelector } from '../redux/redux-utils'; +import { useStyles } from '../themes/colors'; + +import type { ChatNavigationProp } from './chat.react'; +import { Timestamp } from './timestamp.react'; export type ChatRobotextMessageInfoItemWithHeight = {| itemType: 'message', messageShapeType: 'robotext', messageInfo: RobotextMessageInfo, threadInfo: ThreadInfo, startsConversation: boolean, startsCluster: boolean, endsCluster: boolean, robotext: string, contentHeight: number, |}; function robotextMessageItemHeight( item: ChatRobotextMessageInfoItemWithHeight, ) { return item.contentHeight; } function dummyNodeForRobotextMessageHeightMeasurement(robotext: string) { return ( {robotextToRawString(robotext)} ); } type Props = {| ...React.ElementConfig, +item: ChatRobotextMessageInfoItemWithHeight, +navigation: ChatNavigationProp<'MessageList'>, +focused: boolean, +toggleFocus: (messageKey: string) => void, |}; function RobotextMessage(props: Props) { const { item, navigation, focused, toggleFocus, ...viewProps } = props; const activeTheme = useSelector((state) => state.globalThemeInfo.activeTheme); const styles = useStyles(unboundStyles); let timestamp = null; if (focused || item.startsConversation) { timestamp = ( ); } const robotext = item.robotext; const robotextParts = splitRobotext(robotext); const textParts = []; let keyIndex = 0; for (let splitPart of robotextParts) { if (splitPart === '') { continue; } if (splitPart.charAt(0) !== '<') { const darkColor = activeTheme === 'dark'; const key = `text${keyIndex++}`; textParts.push( {decodeURI(splitPart)} , ); continue; } const { rawText, entityType, id } = parseRobotextEntity(splitPart); if (entityType === 't' && id !== item.messageInfo.threadID) { textParts.push( , ); } else if (entityType === 'c') { textParts.push(); } else { textParts.push(rawText); } } const viewStyle = [styles.robotextContainer]; if (!__DEV__) { // We don't force view height in dev mode because we // want to measure it in Message to see if it's correct viewStyle.push({ height: item.contentHeight }); } const keyboardState = React.useContext(KeyboardContext); const key = messageKey(item.messageInfo); const onPress = React.useCallback(() => { const didDismiss = keyboardState && keyboardState.dismissKeyboardIfShowing(); if (!didDismiss) { toggleFocus(key); } }, [keyboardState, toggleFocus, key]); return ( {timestamp} {textParts} ); } type ThreadEntityProps = {| +id: string, +name: string, +navigation: ChatNavigationProp<'MessageList'>, |}; function ThreadEntity(props: ThreadEntityProps) { const threadID = props.id; const threadInfo = useSelector( (state) => threadInfoSelector(state)[threadID], ); const styles = useStyles(unboundStyles); const { navigate } = props.navigation; const onPressThread = React.useCallback(() => { invariant(threadInfo, 'onPressThread should have threadInfo'); navigate({ name: MessageListRouteName, params: { threadInfo }, key: `${MessageListRouteName}${threadInfo.id}`, }); }, [threadInfo, navigate]); if (!threadInfo) { return {props.name}; } return ( {props.name} ); } function ColorEntity(props: {| +color: string |}) { const colorStyle = { color: props.color }; return {props.color}; } const unboundStyles = { link: { color: 'link', }, robotextContainer: { paddingTop: 6, paddingBottom: 11, paddingHorizontal: 24, }, robotext: { color: 'listForegroundSecondaryLabel', fontFamily: 'Arial', fontSize: 15, textAlign: 'center', }, dummyRobotext: { fontFamily: 'Arial', fontSize: 15, textAlign: 'center', }, }; export { robotextMessageItemHeight, dummyNodeForRobotextMessageHeightMeasurement, RobotextMessage, }; diff --git a/native/chat/rounded-corners.js b/native/chat/rounded-corners.js index ae7da87fc..14ca69581 100644 --- a/native/chat/rounded-corners.js +++ b/native/chat/rounded-corners.js @@ -1,35 +1,36 @@ // @flow -import type { ChatMessageInfoItemWithHeight } from './message.react'; import type { Corners } from 'lib/types/media-types'; +import type { ChatMessageInfoItemWithHeight } from './message.react'; + function filterCorners(corners: Corners, item: ChatMessageInfoItemWithHeight) { const { startsCluster, endsCluster } = item; const { isViewer } = item.messageInfo.creator; const { topLeft, topRight, bottomLeft, bottomRight } = corners; return { topLeft: topLeft && (isViewer || startsCluster), topRight: topRight && (!isViewer || startsCluster), bottomLeft: bottomLeft && (isViewer || endsCluster), bottomRight: bottomRight && (!isViewer || endsCluster), }; } const allCorners = { topLeft: true, topRight: true, bottomLeft: true, bottomRight: true, }; function getRoundedContainerStyle(corners: Corners, borderRadius?: number = 8) { const { topLeft, topRight, bottomLeft, bottomRight } = corners; return { borderTopLeftRadius: topLeft ? borderRadius : 0, borderTopRightRadius: topRight ? borderRadius : 0, borderBottomLeftRadius: bottomLeft ? borderRadius : 0, borderBottomRightRadius: bottomRight ? borderRadius : 0, }; } export { allCorners, filterCorners, getRoundedContainerStyle }; diff --git a/native/chat/settings/add-users-modal.react.js b/native/chat/settings/add-users-modal.react.js index bc6471887..87fb57503 100644 --- a/native/chat/settings/add-users-modal.react.js +++ b/native/chat/settings/add-users-modal.react.js @@ -1,358 +1,356 @@ // @flow +import invariant from 'invariant'; +import { + changeThreadSettingsActionTypes, + changeThreadSettings, +} from 'lib/actions/thread-actions'; +import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; +import { threadInfoSelector } from 'lib/selectors/thread-selectors'; +import { + userInfoSelectorForPotentialMembers, + userSearchIndexForPotentialMembers, +} from 'lib/selectors/user-selectors'; +import SearchIndex from 'lib/shared/search-index'; +import { getPotentialMemberItems } from 'lib/shared/search-utils'; +import { threadActualMembers } from 'lib/shared/thread-utils'; +import { loadingStatusPropType } from 'lib/types/loading-types'; +import type { LoadingStatus } from 'lib/types/loading-types'; import { type ThreadInfo, threadInfoPropType, type ChangeThreadSettingsPayload, type UpdateThreadRequest, } from 'lib/types/thread-types'; import { type AccountUserInfo, accountUserInfoPropType, } from 'lib/types/user-types'; import type { DispatchActionPromise } from 'lib/utils/action-utils'; -import type { LoadingStatus } from 'lib/types/loading-types'; -import { loadingStatusPropType } from 'lib/types/loading-types'; -import type { RootNavigationProp } from '../../navigation/root-navigator.react'; -import type { NavigationRoute } from '../../navigation/route-names'; - -import * as React from 'react'; -import { View, Text, ActivityIndicator, Alert } from 'react-native'; -import PropTypes from 'prop-types'; -import invariant from 'invariant'; -import { createSelector } from 'reselect'; - -import { - userInfoSelectorForPotentialMembers, - userSearchIndexForPotentialMembers, -} from 'lib/selectors/user-selectors'; -import SearchIndex from 'lib/shared/search-index'; -import { getPotentialMemberItems } from 'lib/shared/search-utils'; -import { - changeThreadSettingsActionTypes, - changeThreadSettings, -} from 'lib/actions/thread-actions'; -import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; -import { threadActualMembers } from 'lib/shared/thread-utils'; -import { threadInfoSelector } from 'lib/selectors/thread-selectors'; import { useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; +import PropTypes from 'prop-types'; +import * as React from 'react'; +import { View, Text, ActivityIndicator, Alert } from 'react-native'; +import { createSelector } from 'reselect'; -import UserList from '../../components/user-list.react'; -import TagInput from '../../components/tag-input.react'; import Button from '../../components/button.react'; import Modal from '../../components/modal.react'; -import { useStyles } from '../../themes/colors'; +import TagInput from '../../components/tag-input.react'; +import UserList from '../../components/user-list.react'; +import type { RootNavigationProp } from '../../navigation/root-navigator.react'; +import type { NavigationRoute } from '../../navigation/route-names'; import { useSelector } from '../../redux/redux-utils'; +import { useStyles } from '../../themes/colors'; const tagInputProps = { placeholder: 'Select users to add', autoFocus: true, returnKeyType: 'go', }; export type AddUsersModalParams = {| presentedFrom: string, threadInfo: ThreadInfo, |}; type BaseProps = {| +navigation: RootNavigationProp<'AddUsersModal'>, +route: NavigationRoute<'AddUsersModal'>, |}; type Props = {| ...BaseProps, // Redux state +parentThreadInfo: ?ThreadInfo, +otherUserInfos: { [id: string]: AccountUserInfo }, +userSearchIndex: SearchIndex, +changeThreadSettingsLoadingStatus: LoadingStatus, +styles: typeof unboundStyles, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +changeThreadSettings: ( request: UpdateThreadRequest, ) => Promise, |}; type State = {| +usernameInputText: string, +userInfoInputArray: $ReadOnlyArray, |}; type PropsAndState = {| ...Props, ...State |}; class AddUsersModal extends React.PureComponent { static propTypes = { navigation: PropTypes.shape({ goBackOnce: PropTypes.func.isRequired, }).isRequired, route: PropTypes.shape({ params: PropTypes.shape({ threadInfo: threadInfoPropType.isRequired, }).isRequired, }).isRequired, parentThreadInfo: threadInfoPropType, otherUserInfos: PropTypes.objectOf(accountUserInfoPropType).isRequired, userSearchIndex: PropTypes.instanceOf(SearchIndex).isRequired, changeThreadSettingsLoadingStatus: loadingStatusPropType.isRequired, styles: PropTypes.objectOf(PropTypes.object).isRequired, dispatchActionPromise: PropTypes.func.isRequired, changeThreadSettings: PropTypes.func.isRequired, }; state: State = { usernameInputText: '', userInfoInputArray: [], }; tagInput: ?TagInput = null; userSearchResultsSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.usernameInputText, (propsAndState: PropsAndState) => propsAndState.otherUserInfos, (propsAndState: PropsAndState) => propsAndState.userSearchIndex, (propsAndState: PropsAndState) => propsAndState.userInfoInputArray, (propsAndState: PropsAndState) => propsAndState.route.params.threadInfo, (propsAndState: PropsAndState) => propsAndState.parentThreadInfo, ( text: string, userInfos: { [id: string]: AccountUserInfo }, searchIndex: SearchIndex, userInfoInputArray: $ReadOnlyArray, threadInfo: ThreadInfo, parentThreadInfo: ?ThreadInfo, ) => { const excludeUserIDs = userInfoInputArray .map((userInfo) => userInfo.id) .concat(threadActualMembers(threadInfo.members)); return getPotentialMemberItems( text, userInfos, searchIndex, excludeUserIDs, parentThreadInfo, ); }, ); get userSearchResults() { return this.userSearchResultsSelector({ ...this.props, ...this.state }); } render() { let addButton = null; const inputLength = this.state.userInfoInputArray.length; if (inputLength > 0) { let activityIndicator = null; if (this.props.changeThreadSettingsLoadingStatus === 'loading') { activityIndicator = ( ); } const addButtonText = `Add (${inputLength})`; addButton = ( ); } let cancelButton; if (this.props.changeThreadSettingsLoadingStatus !== 'loading') { cancelButton = ( ); } else { cancelButton = ; } const inputProps = { ...tagInputProps, onSubmitEditing: this.onPressAdd, }; return ( {cancelButton} {addButton} ); } close = () => { this.props.navigation.goBackOnce(); }; tagInputRef = (tagInput: ?TagInput) => { this.tagInput = tagInput; }; onChangeTagInput = (userInfoInputArray: $ReadOnlyArray) => { if (this.props.changeThreadSettingsLoadingStatus === 'loading') { return; } this.setState({ userInfoInputArray }); }; tagDataLabelExtractor = (userInfo: AccountUserInfo) => userInfo.username; setUsernameInputText = (text: string) => { if (this.props.changeThreadSettingsLoadingStatus === 'loading') { return; } this.setState({ usernameInputText: text }); }; onUserSelect = (userID: string) => { if (this.props.changeThreadSettingsLoadingStatus === 'loading') { return; } for (let existingUserInfo of this.state.userInfoInputArray) { if (userID === existingUserInfo.id) { return; } } const userInfoInputArray = [ ...this.state.userInfoInputArray, this.props.otherUserInfos[userID], ]; this.setState({ userInfoInputArray, usernameInputText: '', }); }; onPressAdd = () => { if (this.state.userInfoInputArray.length === 0) { return; } this.props.dispatchActionPromise( changeThreadSettingsActionTypes, this.addUsersToThread(), ); }; async addUsersToThread() { try { const newMemberIDs = this.state.userInfoInputArray.map( (userInfo) => userInfo.id, ); const result = await this.props.changeThreadSettings({ threadID: this.props.route.params.threadInfo.id, changes: { newMemberIDs }, }); this.close(); return result; } catch (e) { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: this.onUnknownErrorAlertAcknowledged }], { cancelable: false }, ); throw e; } } onErrorAcknowledged = () => { invariant(this.tagInput, 'nameInput should be set'); this.tagInput.focus(); }; onUnknownErrorAlertAcknowledged = () => { this.setState( { userInfoInputArray: [], usernameInputText: '', }, this.onErrorAcknowledged, ); }; } const unboundStyles = { activityIndicator: { paddingRight: 6, }, addButton: { backgroundColor: 'greenButton', borderRadius: 3, flexDirection: 'row', paddingHorizontal: 10, paddingVertical: 4, }, addText: { color: 'white', fontSize: 18, }, buttons: { flexDirection: 'row', justifyContent: 'space-between', marginTop: 12, }, cancelButton: { backgroundColor: 'modalButton', borderRadius: 3, paddingHorizontal: 10, paddingVertical: 4, }, cancelText: { color: 'modalButtonLabel', fontSize: 18, }, }; export default React.memo(function ConnectedAddUsersModal( props: BaseProps, ) { const { parentThreadID } = props.route.params.threadInfo; const parentThreadInfo = useSelector((state) => parentThreadID ? threadInfoSelector(state)[parentThreadID] : null, ); const otherUserInfos = useSelector(userInfoSelectorForPotentialMembers); const userSearchIndex = useSelector(userSearchIndexForPotentialMembers); const changeThreadSettingsLoadingStatus = useSelector( createLoadingStatusSelector(changeThreadSettingsActionTypes), ); const styles = useStyles(unboundStyles); const dispatchActionPromise = useDispatchActionPromise(); const callChangeThreadSettings = useServerCall(changeThreadSettings); return ( ); }); diff --git a/native/chat/settings/color-picker-modal.react.js b/native/chat/settings/color-picker-modal.react.js index 4b3755370..e6e18c5a2 100644 --- a/native/chat/settings/color-picker-modal.react.js +++ b/native/chat/settings/color-picker-modal.react.js @@ -1,182 +1,180 @@ // @flow -import type { AppState } from '../../redux/redux-setup'; -import type { DispatchActionPromise } from 'lib/utils/action-utils'; +import { + changeThreadSettingsActionTypes, + changeThreadSettings, +} from 'lib/actions/thread-actions'; import { type ThreadInfo, threadInfoPropType, type ChangeThreadSettingsPayload, type UpdateThreadRequest, } from 'lib/types/thread-types'; -import type { RootNavigationProp } from '../../navigation/root-navigator.react'; -import type { NavigationRoute } from '../../navigation/route-names'; - -import * as React from 'react'; +import type { DispatchActionPromise } from 'lib/utils/action-utils'; +import { connect } from 'lib/utils/redux-utils'; import PropTypes from 'prop-types'; +import * as React from 'react'; import { TouchableHighlight, Alert } from 'react-native'; import Icon from 'react-native-vector-icons/FontAwesome'; -import { connect } from 'lib/utils/redux-utils'; -import { - changeThreadSettingsActionTypes, - changeThreadSettings, -} from 'lib/actions/thread-actions'; - -import Modal from '../../components/modal.react'; import ColorPicker from '../../components/color-picker.react'; +import Modal from '../../components/modal.react'; +import type { RootNavigationProp } from '../../navigation/root-navigator.react'; +import type { NavigationRoute } from '../../navigation/route-names'; +import type { AppState } from '../../redux/redux-setup'; import { type Colors, colorsPropType, colorsSelector, styleSelector, } from '../../themes/colors'; export type ColorPickerModalParams = {| presentedFrom: string, color: string, threadInfo: ThreadInfo, setColor: (color: string) => void, |}; type Props = {| navigation: RootNavigationProp<'ColorPickerModal'>, route: NavigationRoute<'ColorPickerModal'>, // Redux state colors: Colors, styles: typeof styles, windowWidth: number, // Redux dispatch functions dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs changeThreadSettings: ( request: UpdateThreadRequest, ) => Promise, |}; class ColorPickerModal extends React.PureComponent { static propTypes = { navigation: PropTypes.shape({ goBackOnce: PropTypes.func.isRequired, }).isRequired, route: PropTypes.shape({ params: PropTypes.shape({ color: PropTypes.string.isRequired, threadInfo: threadInfoPropType.isRequired, setColor: PropTypes.func.isRequired, }).isRequired, }).isRequired, colors: colorsPropType.isRequired, styles: PropTypes.objectOf(PropTypes.object).isRequired, windowWidth: PropTypes.number.isRequired, dispatchActionPromise: PropTypes.func.isRequired, changeThreadSettings: PropTypes.func.isRequired, }; render() { const { color, threadInfo } = this.props.route.params; // Based on the assumption we are always in portrait, // and consequently width is the lowest dimensions const modalStyle = { height: this.props.windowWidth - 5 }; return ( ); } close = () => { this.props.navigation.goBackOnce(); }; onColorSelected = (color: string) => { const colorEditValue = color.substr(1); this.props.route.params.setColor(colorEditValue); this.close(); this.props.dispatchActionPromise( changeThreadSettingsActionTypes, this.editColor(colorEditValue), { customKeyName: `${changeThreadSettingsActionTypes.started}:color` }, ); }; async editColor(newColor: string) { const threadID = this.props.route.params.threadInfo.id; try { return await this.props.changeThreadSettings({ threadID, changes: { color: newColor }, }); } catch (e) { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: this.onErrorAcknowledged }], { cancelable: false }, ); throw e; } } onErrorAcknowledged = () => { const { threadInfo, setColor } = this.props.route.params; setColor(threadInfo.color); }; } const styles = { closeButton: { borderRadius: 3, height: 18, position: 'absolute', right: 5, top: 5, width: 18, }, closeButtonIcon: { color: 'modalBackgroundSecondaryLabel', left: 3, position: 'absolute', }, colorPicker: { bottom: 10, left: 10, position: 'absolute', right: 10, top: 10, }, colorPickerContainer: { backgroundColor: 'modalBackground', borderRadius: 5, flex: 0, marginHorizontal: 15, marginVertical: 20, }, }; const stylesSelector = styleSelector(styles); export default connect( (state: AppState) => ({ colors: colorsSelector(state), styles: stylesSelector(state), windowWidth: state.dimensions.width, }), { changeThreadSettings }, )(ColorPickerModal); diff --git a/native/chat/settings/compose-subthread-modal.react.js b/native/chat/settings/compose-subthread-modal.react.js index 5fbbd7421..7019848ad 100644 --- a/native/chat/settings/compose-subthread-modal.react.js +++ b/native/chat/settings/compose-subthread-modal.react.js @@ -1,167 +1,165 @@ // @flow +import { threadTypeDescriptions } from 'lib/shared/thread-utils'; import { type ThreadInfo, threadInfoPropType, threadTypes, } from 'lib/types/thread-types'; -import type { AppState } from '../../redux/redux-setup'; -import type { RootNavigationProp } from '../../navigation/root-navigator.react'; -import type { NavigationRoute } from '../../navigation/route-names'; - -import * as React from 'react'; +import { connect } from 'lib/utils/redux-utils'; import PropTypes from 'prop-types'; +import * as React from 'react'; import { Text } from 'react-native'; -import Icon from 'react-native-vector-icons/MaterialIcons'; import IonIcon from 'react-native-vector-icons/Ionicons'; - -import { threadTypeDescriptions } from 'lib/shared/thread-utils'; -import { connect } from 'lib/utils/redux-utils'; +import Icon from 'react-native-vector-icons/MaterialIcons'; import Button from '../../components/button.react'; -import { ComposeThreadRouteName } from '../../navigation/route-names'; import Modal from '../../components/modal.react'; +import type { RootNavigationProp } from '../../navigation/root-navigator.react'; +import type { NavigationRoute } from '../../navigation/route-names'; +import { ComposeThreadRouteName } from '../../navigation/route-names'; +import type { AppState } from '../../redux/redux-setup'; import { type Colors, colorsPropType, colorsSelector, styleSelector, } from '../../themes/colors'; export type ComposeSubthreadModalParams = {| presentedFrom: string, threadInfo: ThreadInfo, |}; type Props = {| navigation: RootNavigationProp<'ComposeSubthreadModal'>, route: NavigationRoute<'ComposeSubthreadModal'>, // Redux state colors: Colors, styles: typeof styles, |}; class ComposeSubthreadModal extends React.PureComponent { static propTypes = { navigation: PropTypes.shape({ navigate: PropTypes.func.isRequired, }).isRequired, route: PropTypes.shape({ params: PropTypes.shape({ presentedFrom: PropTypes.string.isRequired, threadInfo: threadInfoPropType.isRequired, }).isRequired, }).isRequired, colors: colorsPropType.isRequired, styles: PropTypes.objectOf(PropTypes.object).isRequired, }; render() { return ( Thread type ); } onPressOpen = () => { const threadInfo = this.props.route.params.threadInfo; this.props.navigation.navigate({ name: ComposeThreadRouteName, params: { threadType: threadTypes.CHAT_NESTED_OPEN, parentThreadInfo: threadInfo, }, key: `${ComposeThreadRouteName}|${threadInfo.id}|${threadTypes.CHAT_NESTED_OPEN}`, }); }; onPressSecret = () => { const threadInfo = this.props.route.params.threadInfo; this.props.navigation.navigate({ name: ComposeThreadRouteName, params: { threadType: threadTypes.CHAT_SECRET, parentThreadInfo: threadInfo, }, key: `${ComposeThreadRouteName}|${threadInfo.id}|${threadTypes.CHAT_SECRET}`, }); }; } const styles = { forwardIcon: { color: 'link', paddingLeft: 10, }, modal: { flex: 0, }, option: { alignItems: 'center', flexDirection: 'row', paddingHorizontal: 10, paddingVertical: 20, }, optionExplanation: { color: 'modalBackgroundLabel', flex: 1, fontSize: 14, paddingLeft: 20, textAlign: 'center', }, optionText: { color: 'modalBackgroundLabel', fontSize: 20, paddingLeft: 5, }, visibility: { color: 'modalBackgroundLabel', fontSize: 24, textAlign: 'center', }, visibilityIcon: { color: 'modalBackgroundLabel', paddingRight: 3, }, }; const stylesSelector = styleSelector(styles); export default connect((state: AppState) => ({ colors: colorsSelector(state), styles: stylesSelector(state), }))(ComposeSubthreadModal); diff --git a/native/chat/settings/delete-thread.react.js b/native/chat/settings/delete-thread.react.js index f334c9c1c..6ed61403e 100644 --- a/native/chat/settings/delete-thread.react.js +++ b/native/chat/settings/delete-thread.react.js @@ -1,314 +1,312 @@ // @flow -import type { LoadingStatus } from 'lib/types/loading-types'; -import type { ThreadInfo, LeaveThreadPayload } from 'lib/types/thread-types'; -import type { GlobalTheme } from '../../types/themes'; -import type { ChatNavigationProp } from '../chat.react'; -import type { NavigationRoute } from '../../navigation/route-names'; - -import * as React from 'react'; -import { - Text, - View, - TextInput, - ScrollView, - Alert, - ActivityIndicator, -} from 'react-native'; import invariant from 'invariant'; - import { deleteThreadActionTypes, deleteThread, } from 'lib/actions/thread-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { threadInfoSelector } from 'lib/selectors/thread-selectors'; import { identifyInvalidatedThreads } from 'lib/shared/thread-utils'; +import type { LoadingStatus } from 'lib/types/loading-types'; +import type { ThreadInfo, LeaveThreadPayload } from 'lib/types/thread-types'; import { useServerCall, useDispatchActionPromise, type DispatchActionPromise, } from 'lib/utils/action-utils'; +import * as React from 'react'; +import { + Text, + View, + TextInput, + ScrollView, + Alert, + ActivityIndicator, +} from 'react-native'; import Button from '../../components/button.react'; -import { type Colors, useColors, useStyles } from '../../themes/colors'; +import { clearThreadsActionType } from '../../navigation/action-types'; import { NavContext, type NavAction, } from '../../navigation/navigation-context'; -import { clearThreadsActionType } from '../../navigation/action-types'; +import type { NavigationRoute } from '../../navigation/route-names'; import { useSelector } from '../../redux/redux-utils'; +import { type Colors, useColors, useStyles } from '../../themes/colors'; +import type { GlobalTheme } from '../../types/themes'; +import type { ChatNavigationProp } from '../chat.react'; export type DeleteThreadParams = {| +threadInfo: ThreadInfo, |}; type BaseProps = {| +navigation: ChatNavigationProp<'DeleteThread'>, +route: NavigationRoute<'DeleteThread'>, |}; type Props = {| ...BaseProps, // Redux state +threadInfo: ?ThreadInfo, +loadingStatus: LoadingStatus, +activeTheme: ?GlobalTheme, +colors: Colors, +styles: typeof unboundStyles, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +deleteThread: ( threadID: string, currentAccountPassword: string, ) => Promise, // withNavContext +navDispatch: (action: NavAction) => void, |}; type State = {| +password: string, |}; class DeleteThread extends React.PureComponent { state: State = { password: '', }; mounted = false; passwordInput: ?React.ElementRef; static getThreadInfo(props: Props): ThreadInfo { const { threadInfo } = props; if (threadInfo) { return threadInfo; } return props.route.params.threadInfo; } componentDidMount() { this.mounted = true; } componentWillUnmount() { this.mounted = false; } guardedSetState(change, callback) { if (this.mounted) { this.setState(change, callback); } } componentDidUpdate(prevProps: Props) { const oldReduxThreadInfo = prevProps.threadInfo; const newReduxThreadInfo = this.props.threadInfo; if (newReduxThreadInfo && newReduxThreadInfo !== oldReduxThreadInfo) { this.props.navigation.setParams({ threadInfo: newReduxThreadInfo }); } } render() { const buttonContent = this.props.loadingStatus === 'loading' ? ( ) : ( Delete thread ); const threadInfo = DeleteThread.getThreadInfo(this.props); const { panelForegroundTertiaryLabel } = this.props.colors; return ( {`The thread "${threadInfo.uiName}" will be permanently deleted. `} There is no way to reverse this. PASSWORD ); } onChangePasswordText = (newPassword: string) => { this.guardedSetState({ password: newPassword }); }; passwordInputRef = (passwordInput: ?React.ElementRef) => { this.passwordInput = passwordInput; }; focusPasswordInput = () => { invariant(this.passwordInput, 'passwordInput should be set'); this.passwordInput.focus(); }; submitDeletion = () => { this.props.dispatchActionPromise( deleteThreadActionTypes, this.deleteThread(), ); }; async deleteThread() { const threadInfo = DeleteThread.getThreadInfo(this.props); const { navDispatch } = this.props; navDispatch({ type: clearThreadsActionType, payload: { threadIDs: [threadInfo.id] }, }); try { const result = await this.props.deleteThread( threadInfo.id, this.state.password, ); const invalidated = identifyInvalidatedThreads( result.updatesResult.newUpdates, ); navDispatch({ type: clearThreadsActionType, payload: { threadIDs: [...invalidated] }, }); return result; } catch (e) { if ( e.message === 'invalid_credentials' || e.message === 'invalid_parameters' ) { Alert.alert( 'Incorrect password', 'The password you entered is incorrect', [{ text: 'OK', onPress: this.onErrorAlertAcknowledged }], { cancelable: false }, ); } else { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: this.onErrorAlertAcknowledged }], { cancelable: false }, ); } } } onErrorAlertAcknowledged = () => { this.guardedSetState({ password: '' }, this.focusPasswordInput); }; } const unboundStyles = { deleteButton: { backgroundColor: 'redButton', borderRadius: 5, flex: 1, marginHorizontal: 24, marginVertical: 12, padding: 12, }, deleteText: { color: 'white', fontSize: 18, textAlign: 'center', }, header: { color: 'panelBackgroundLabel', fontSize: 12, fontWeight: '400', paddingBottom: 3, paddingHorizontal: 24, }, input: { color: 'panelForegroundLabel', flex: 1, fontFamily: 'Arial', fontSize: 16, paddingVertical: 0, borderBottomColor: 'transparent', }, scrollView: { backgroundColor: 'panelBackground', }, scrollViewContentContainer: { paddingTop: 24, }, section: { backgroundColor: 'panelForeground', borderBottomWidth: 1, borderColor: 'panelForegroundBorder', borderTopWidth: 1, flexDirection: 'row', justifyContent: 'space-between', marginBottom: 24, paddingHorizontal: 24, paddingVertical: 12, }, warningText: { color: 'panelForegroundLabel', fontSize: 16, marginBottom: 24, marginHorizontal: 24, textAlign: 'center', }, }; const loadingStatusSelector = createLoadingStatusSelector( deleteThreadActionTypes, ); export default React.memo(function ConnectedDeleteThread( props: BaseProps, ) { const threadID = props.route.params.threadInfo.id; const threadInfo = useSelector( (state) => threadInfoSelector(state)[threadID], ); const loadingStatus = useSelector(loadingStatusSelector); const activeTheme = useSelector((state) => state.globalThemeInfo.activeTheme); const colors = useColors(); const styles = useStyles(unboundStyles); const dispatchActionPromise = useDispatchActionPromise(); const callDeleteThread = useServerCall(deleteThread); const navContext = React.useContext(NavContext); invariant(navContext, 'NavContext should be set in DeleteThread'); const navDispatch = navContext.dispatch; return ( ); }); diff --git a/native/chat/settings/save-setting-button.react.js b/native/chat/settings/save-setting-button.react.js index 4628d7ae2..efd8ad0bf 100644 --- a/native/chat/settings/save-setting-button.react.js +++ b/native/chat/settings/save-setting-button.react.js @@ -1,45 +1,43 @@ // @flow -import type { AppState } from '../../redux/redux-setup'; - +import { connect } from 'lib/utils/redux-utils'; import * as React from 'react'; import { TouchableOpacity } from 'react-native'; import Icon from 'react-native-vector-icons/Ionicons'; -import { connect } from 'lib/utils/redux-utils'; - +import type { AppState } from '../../redux/redux-setup'; import { styleSelector } from '../../themes/colors'; type Props = {| onPress: () => void, // Redux state styles: typeof styles, |}; function SaveSettingButton(props: Props) { return ( ); } const styles = { container: { width: 26, }, editIcon: { color: 'greenButton', position: 'absolute', right: 0, top: -3, }, }; const stylesSelector = styleSelector(styles); export default connect((state: AppState) => ({ styles: stylesSelector(state), }))(SaveSettingButton); diff --git a/native/chat/settings/thread-settings-category.react.js b/native/chat/settings/thread-settings-category.react.js index ae1355050..f54dd0d32 100644 --- a/native/chat/settings/thread-settings-category.react.js +++ b/native/chat/settings/thread-settings-category.react.js @@ -1,116 +1,114 @@ // @flow -import type { AppState } from '../../redux/redux-setup'; - -import * as React from 'react'; -import { View, Text, Platform } from 'react-native'; import invariant from 'invariant'; - import { connect } from 'lib/utils/redux-utils'; +import * as React from 'react'; +import { View, Text, Platform } from 'react-native'; +import type { AppState } from '../../redux/redux-setup'; import { styleSelector } from '../../themes/colors'; export type CategoryType = 'full' | 'outline' | 'unpadded'; type HeaderProps = {| type: CategoryType, title: string, // Redux state styles: typeof styles, |}; function ThreadSettingsCategoryHeader(props: HeaderProps) { let contentStyle, paddingStyle; if (props.type === 'full') { contentStyle = props.styles.fullHeader; paddingStyle = props.styles.fullHeaderPadding; } else if (props.type === 'outline') { // nothing } else if (props.type === 'unpadded') { contentStyle = props.styles.fullHeader; } else { invariant(false, 'invalid ThreadSettingsCategory type'); } return ( {props.title.toUpperCase()} ); } type FooterProps = {| type: CategoryType, // Redux state styles: typeof styles, |}; function ThreadSettingsCategoryFooter(props: FooterProps) { let contentStyle, paddingStyle; if (props.type === 'full') { contentStyle = props.styles.fullFooter; paddingStyle = props.styles.fullFooterPadding; } else if (props.type === 'outline') { // nothing } else if (props.type === 'unpadded') { contentStyle = props.styles.fullFooter; } else { invariant(false, 'invalid ThreadSettingsCategory type'); } return ( ); } const paddingHeight = Platform.select({ android: 6.5, default: 6, }); const styles = { footer: { marginBottom: 16, }, fullFooter: { borderColor: 'panelForegroundBorder', borderTopWidth: 1, }, fullFooterPadding: { backgroundColor: 'panelForeground', height: paddingHeight, }, fullHeader: { borderBottomWidth: 1, borderColor: 'panelForegroundBorder', }, fullHeaderPadding: { backgroundColor: 'panelForeground', height: paddingHeight, margin: 0, }, header: { marginTop: 16, }, title: { color: 'panelBackgroundLabel', fontSize: 12, fontWeight: '400', paddingBottom: 3, paddingLeft: 24, }, }; const stylesSelector = styleSelector(styles); const WrappedThreadSettingsCategoryHeader = connect((state: AppState) => ({ styles: stylesSelector(state), }))(ThreadSettingsCategoryHeader); const WrappedThreadSettingsCategoryFooter = connect((state: AppState) => ({ styles: stylesSelector(state), }))(ThreadSettingsCategoryFooter); export { WrappedThreadSettingsCategoryHeader as ThreadSettingsCategoryHeader, WrappedThreadSettingsCategoryFooter as ThreadSettingsCategoryFooter, }; diff --git a/native/chat/settings/thread-settings-child-thread.react.js b/native/chat/settings/thread-settings-child-thread.react.js index 34781fb37..dd731fe14 100644 --- a/native/chat/settings/thread-settings-child-thread.react.js +++ b/native/chat/settings/thread-settings-child-thread.react.js @@ -1,88 +1,88 @@ // @flow import type { ThreadInfo } from 'lib/types/thread-types'; -import type { ThreadSettingsNavigate } from './thread-settings.react'; - import * as React from 'react'; import { View, Platform } from 'react-native'; -import { MessageListRouteName } from '../../navigation/route-names'; import Button from '../../components/button.react'; import ColorSplotch from '../../components/color-splotch.react'; +import { SingleLine } from '../../components/single-line.react'; import ThreadIcon from '../../components/thread-icon.react'; +import { MessageListRouteName } from '../../navigation/route-names'; import { useColors, useStyles } from '../../themes/colors'; -import { SingleLine } from '../../components/single-line.react'; + +import type { ThreadSettingsNavigate } from './thread-settings.react'; type Props = {| +threadInfo: ThreadInfo, +navigate: ThreadSettingsNavigate, +firstListItem: boolean, +lastListItem: boolean, |}; function ThreadSettingsChildThread(props: Props) { const { navigate, threadInfo } = props; const onPress = React.useCallback(() => { navigate({ name: MessageListRouteName, params: { threadInfo }, key: `${MessageListRouteName}${threadInfo.id}`, }); }, [navigate, threadInfo]); const styles = useStyles(unboundStyles); const colors = useColors(); const firstItem = props.firstListItem ? null : styles.topBorder; const lastItem = props.lastListItem ? styles.lastButton : null; return ( ); } const unboundStyles = { button: { flex: 1, flexDirection: 'row', paddingVertical: 8, paddingLeft: 12, paddingRight: 10, alignItems: 'center', }, topBorder: { borderColor: 'panelForegroundBorder', borderTopWidth: 1, }, container: { backgroundColor: 'panelForeground', flex: 1, paddingHorizontal: 12, }, lastButton: { paddingBottom: Platform.OS === 'ios' ? 12 : 10, paddingTop: 8, }, leftSide: { flex: 1, flexDirection: 'row', alignItems: 'center', }, text: { flex: 1, color: 'link', fontSize: 16, paddingLeft: 8, }, }; export default ThreadSettingsChildThread; diff --git a/native/chat/settings/thread-settings-color.react.js b/native/chat/settings/thread-settings-color.react.js index 61fd1009e..fe9b2d1df 100644 --- a/native/chat/settings/thread-settings-color.react.js +++ b/native/chat/settings/thread-settings-color.react.js @@ -1,141 +1,140 @@ // @flow -import { type ThreadInfo, threadInfoPropType } from 'lib/types/thread-types'; -import type { LoadingStatus } from 'lib/types/loading-types'; +import { changeThreadSettingsActionTypes } from 'lib/actions/thread-actions'; +import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { loadingStatusPropType } from 'lib/types/loading-types'; -import type { ThreadSettingsNavigate } from './thread-settings.react'; - +import type { LoadingStatus } from 'lib/types/loading-types'; +import { type ThreadInfo, threadInfoPropType } from 'lib/types/thread-types'; +import PropTypes from 'prop-types'; import * as React from 'react'; import { Text, ActivityIndicator, View, Platform } from 'react-native'; -import PropTypes from 'prop-types'; -import { changeThreadSettingsActionTypes } from 'lib/actions/thread-actions'; -import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; - -import EditSettingButton from '../../components/edit-setting-button.react'; import ColorSplotch from '../../components/color-splotch.react'; +import EditSettingButton from '../../components/edit-setting-button.react'; import { ColorPickerModalRouteName } from '../../navigation/route-names'; +import { useSelector } from '../../redux/redux-utils'; import { type Colors, colorsPropType, useColors, useStyles, } from '../../themes/colors'; -import { useSelector } from '../../redux/redux-utils'; + +import type { ThreadSettingsNavigate } from './thread-settings.react'; type BaseProps = {| +threadInfo: ThreadInfo, +colorEditValue: string, +setColorEditValue: (color: string) => void, +canChangeSettings: boolean, +navigate: ThreadSettingsNavigate, +threadSettingsRouteKey: string, |}; type Props = {| ...BaseProps, // Redux state +loadingStatus: LoadingStatus, +colors: Colors, +styles: typeof unboundStyles, |}; class ThreadSettingsColor extends React.PureComponent { static propTypes = { threadInfo: threadInfoPropType.isRequired, colorEditValue: PropTypes.string.isRequired, setColorEditValue: PropTypes.func.isRequired, canChangeSettings: PropTypes.bool.isRequired, navigate: PropTypes.func.isRequired, threadSettingsRouteKey: PropTypes.string.isRequired, loadingStatus: loadingStatusPropType.isRequired, colors: colorsPropType.isRequired, styles: PropTypes.objectOf(PropTypes.object).isRequired, }; render() { let colorButton; if (this.props.loadingStatus !== 'loading') { colorButton = ( ); } else { colorButton = ( ); } return ( Color {colorButton} ); } onPressEditColor = () => { this.props.navigate({ name: ColorPickerModalRouteName, params: { presentedFrom: this.props.threadSettingsRouteKey, color: this.props.colorEditValue, threadInfo: this.props.threadInfo, setColor: this.props.setColorEditValue, }, }); }; } const unboundStyles = { colorLine: { lineHeight: Platform.select({ android: 22, default: 25 }), }, colorRow: { backgroundColor: 'panelForeground', flexDirection: 'row', paddingBottom: 8, paddingHorizontal: 24, paddingTop: 4, }, currentValue: { flex: 1, paddingLeft: 4, }, label: { color: 'panelForegroundTertiaryLabel', fontSize: 16, width: 96, }, }; const loadingStatusSelector = createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:color`, ); export default React.memo(function ConnectedThreadSettingsColor( props: BaseProps, ) { const loadingStatus = useSelector(loadingStatusSelector); const colors = useColors(); const styles = useStyles(unboundStyles); return ( ); }); diff --git a/native/chat/settings/thread-settings-delete-thread.react.js b/native/chat/settings/thread-settings-delete-thread.react.js index 6160881c9..b948a6c7c 100644 --- a/native/chat/settings/thread-settings-delete-thread.react.js +++ b/native/chat/settings/thread-settings-delete-thread.react.js @@ -1,64 +1,64 @@ // @flow import type { ThreadInfo } from 'lib/types/thread-types'; -import type { ThreadSettingsNavigate } from './thread-settings.react'; -import type { ViewStyle } from '../../types/styles'; - import * as React from 'react'; import { Text, View } from 'react-native'; import Button from '../../components/button.react'; import { DeleteThreadRouteName } from '../../navigation/route-names'; import { useColors, useStyles } from '../../themes/colors'; +import type { ViewStyle } from '../../types/styles'; + +import type { ThreadSettingsNavigate } from './thread-settings.react'; type Props = {| +threadInfo: ThreadInfo, +navigate: ThreadSettingsNavigate, +buttonStyle: ViewStyle, |}; function ThreadSettingsDeleteThread(props: Props) { const { navigate, threadInfo } = props; const onPress = React.useCallback(() => { navigate({ name: DeleteThreadRouteName, params: { threadInfo }, key: `${DeleteThreadRouteName}${threadInfo.id}`, }); }, [navigate, threadInfo]); const colors = useColors(); const { panelIosHighlightUnderlay } = colors; const styles = useStyles(unboundStyles); return ( ); } const unboundStyles = { button: { flexDirection: 'row', paddingHorizontal: 12, paddingVertical: 10, }, container: { backgroundColor: 'panelForeground', paddingHorizontal: 12, }, text: { color: 'redText', flex: 1, fontSize: 16, }, }; export default ThreadSettingsDeleteThread; diff --git a/native/chat/settings/thread-settings-description.react.js b/native/chat/settings/thread-settings-description.react.js index a8c813c38..7d9b1f338 100644 --- a/native/chat/settings/thread-settings-description.react.js +++ b/native/chat/settings/thread-settings-description.react.js @@ -1,305 +1,304 @@ // @flow +import invariant from 'invariant'; +import { + changeThreadSettingsActionTypes, + changeThreadSettings, +} from 'lib/actions/thread-actions'; +import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; +import { threadHasPermission } from 'lib/shared/thread-utils'; +import type { LoadingStatus } from 'lib/types/loading-types'; +import { loadingStatusPropType } from 'lib/types/loading-types'; import { type ThreadInfo, threadInfoPropType, threadPermissions, type ChangeThreadSettingsPayload, type UpdateThreadRequest, } from 'lib/types/thread-types'; import type { DispatchActionPromise } from 'lib/utils/action-utils'; -import type { LoadingStatus } from 'lib/types/loading-types'; -import { loadingStatusPropType } from 'lib/types/loading-types'; -import type { AppState } from '../../redux/redux-setup'; -import type { - LayoutEvent, - ContentSizeChangeEvent, -} from '../../types/react-native'; - +import { connect } from 'lib/utils/redux-utils'; +import PropTypes from 'prop-types'; import * as React from 'react'; import { Text, Alert, ActivityIndicator, TextInput, View } from 'react-native'; -import PropTypes from 'prop-types'; -import invariant from 'invariant'; import Icon from 'react-native-vector-icons/FontAwesome'; -import { connect } from 'lib/utils/redux-utils'; -import { - changeThreadSettingsActionTypes, - changeThreadSettings, -} from 'lib/actions/thread-actions'; -import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; -import { threadHasPermission } from 'lib/shared/thread-utils'; - -import EditSettingButton from '../../components/edit-setting-button.react'; -import SaveSettingButton from './save-setting-button.react'; -import { - ThreadSettingsCategoryHeader, - ThreadSettingsCategoryFooter, -} from './thread-settings-category.react'; import Button from '../../components/button.react'; +import EditSettingButton from '../../components/edit-setting-button.react'; +import type { AppState } from '../../redux/redux-setup'; import { type Colors, colorsPropType, colorsSelector, styleSelector, } from '../../themes/colors'; +import type { + LayoutEvent, + ContentSizeChangeEvent, +} from '../../types/react-native'; + +import SaveSettingButton from './save-setting-button.react'; +import { + ThreadSettingsCategoryHeader, + ThreadSettingsCategoryFooter, +} from './thread-settings-category.react'; type Props = {| threadInfo: ThreadInfo, descriptionEditValue: ?string, setDescriptionEditValue: (value: ?string, callback?: () => void) => void, descriptionTextHeight: ?number, setDescriptionTextHeight: (number: number) => void, canChangeSettings: boolean, // Redux state loadingStatus: LoadingStatus, colors: Colors, styles: typeof styles, // Redux dispatch functions dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs changeThreadSettings: ( update: UpdateThreadRequest, ) => Promise, |}; class ThreadSettingsDescription extends React.PureComponent { static propTypes = { threadInfo: threadInfoPropType.isRequired, descriptionEditValue: PropTypes.string, setDescriptionEditValue: PropTypes.func.isRequired, descriptionTextHeight: PropTypes.number, setDescriptionTextHeight: PropTypes.func.isRequired, canChangeSettings: PropTypes.bool.isRequired, loadingStatus: loadingStatusPropType.isRequired, colors: colorsPropType.isRequired, styles: PropTypes.objectOf(PropTypes.object).isRequired, dispatchActionPromise: PropTypes.func.isRequired, changeThreadSettings: PropTypes.func.isRequired, }; textInput: ?React.ElementRef; render() { if ( this.props.descriptionEditValue !== null && this.props.descriptionEditValue !== undefined ) { let button; if (this.props.loadingStatus !== 'loading') { button = ; } else { button = ( ); } const textInputStyle = {}; if ( this.props.descriptionTextHeight !== undefined && this.props.descriptionTextHeight !== null ) { textInputStyle.height = this.props.descriptionTextHeight; } return ( {button} ); } if (this.props.threadInfo.description) { return ( {this.props.threadInfo.description} ); } const canEditThread = threadHasPermission( this.props.threadInfo, threadPermissions.EDIT_THREAD, ); const { panelIosHighlightUnderlay } = this.props.colors; if (canEditThread) { return ( ); } return null; } textInputRef = (textInput: ?React.ElementRef) => { this.textInput = textInput; }; onLayoutText = (event: LayoutEvent) => { this.props.setDescriptionTextHeight(event.nativeEvent.layout.height); }; onTextInputContentSizeChange = (event: ContentSizeChangeEvent) => { this.props.setDescriptionTextHeight(event.nativeEvent.contentSize.height); }; onPressEdit = () => { this.props.setDescriptionEditValue(this.props.threadInfo.description); }; onSubmit = () => { invariant( this.props.descriptionEditValue !== null && this.props.descriptionEditValue !== undefined, 'should be set', ); const description = this.props.descriptionEditValue.trim(); if (description === this.props.threadInfo.description) { this.props.setDescriptionEditValue(null); return; } const editDescriptionPromise = this.editDescription(description); this.props.dispatchActionPromise( changeThreadSettingsActionTypes, editDescriptionPromise, { customKeyName: `${changeThreadSettingsActionTypes.started}:description`, }, ); editDescriptionPromise.then(() => { this.props.setDescriptionEditValue(null); }); }; async editDescription(newDescription: string) { try { return await this.props.changeThreadSettings({ threadID: this.props.threadInfo.id, changes: { description: newDescription }, }); } catch (e) { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: this.onErrorAcknowledged }], { cancelable: false }, ); throw e; } } onErrorAcknowledged = () => { this.props.setDescriptionEditValue( this.props.threadInfo.description, () => { invariant(this.textInput, 'textInput should be set'); this.textInput.focus(); }, ); }; } const styles = { addDescriptionButton: { flexDirection: 'row', paddingHorizontal: 24, paddingVertical: 10, }, addDescriptionText: { color: 'panelForegroundTertiaryLabel', flex: 1, fontSize: 16, }, editIcon: { color: 'panelForegroundTertiaryLabel', paddingLeft: 10, textAlign: 'right', }, outlineCategory: { backgroundColor: 'panelSecondaryForeground', borderColor: 'panelSecondaryForegroundBorder', borderRadius: 1, borderStyle: 'dashed', borderWidth: 1, marginLeft: -1, marginRight: -1, }, row: { backgroundColor: 'panelForeground', flexDirection: 'row', paddingHorizontal: 24, paddingVertical: 4, }, text: { color: 'panelForegroundSecondaryLabel', flex: 1, fontFamily: 'Arial', fontSize: 16, margin: 0, padding: 0, borderBottomColor: 'transparent', }, }; const stylesSelector = styleSelector(styles); const loadingStatusSelector = createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:description`, ); export default connect( (state: AppState) => ({ loadingStatus: loadingStatusSelector(state), colors: colorsSelector(state), styles: stylesSelector(state), }), { changeThreadSettings }, )(ThreadSettingsDescription); diff --git a/native/chat/settings/thread-settings-home-notifs.react.js b/native/chat/settings/thread-settings-home-notifs.react.js index 2038df0b2..052ca33d8 100644 --- a/native/chat/settings/thread-settings-home-notifs.react.js +++ b/native/chat/settings/thread-settings-home-notifs.react.js @@ -1,122 +1,120 @@ // @flow -import { type ThreadInfo, threadInfoPropType } from 'lib/types/thread-types'; -import type { DispatchActionPromise } from 'lib/utils/action-utils'; -import type { - SubscriptionUpdateRequest, - SubscriptionUpdateResult, -} from 'lib/types/subscription-types'; - -import * as React from 'react'; -import { Text, View, Switch } from 'react-native'; -import PropTypes from 'prop-types'; - import { updateSubscriptionActionTypes, updateSubscription, } from 'lib/actions/user-actions'; +import type { + SubscriptionUpdateRequest, + SubscriptionUpdateResult, +} from 'lib/types/subscription-types'; +import { type ThreadInfo, threadInfoPropType } from 'lib/types/thread-types'; +import type { DispatchActionPromise } from 'lib/utils/action-utils'; import { useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; +import PropTypes from 'prop-types'; +import * as React from 'react'; +import { Text, View, Switch } from 'react-native'; import { useStyles } from '../../themes/colors'; type BaseProps = {| +threadInfo: ThreadInfo, |}; type Props = {| ...BaseProps, // Redux state +styles: typeof unboundStyles, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +updateSubscription: ( subscriptionUpdate: SubscriptionUpdateRequest, ) => Promise, |}; type State = {| +currentValue: boolean, |}; class ThreadSettingsHomeNotifs extends React.PureComponent { static propTypes = { threadInfo: threadInfoPropType.isRequired, styles: PropTypes.objectOf(PropTypes.object).isRequired, dispatchActionPromise: PropTypes.func.isRequired, updateSubscription: PropTypes.func.isRequired, }; constructor(props: Props) { super(props); this.state = { currentValue: !props.threadInfo.currentUser.subscription.home, }; } render() { return ( Background ); } onValueChange = (value: boolean) => { this.setState({ currentValue: value }); this.props.dispatchActionPromise( updateSubscriptionActionTypes, this.props.updateSubscription({ threadID: this.props.threadInfo.id, updatedFields: { home: !value, }, }), ); }; } const unboundStyles = { currentValue: { alignItems: 'flex-end', flex: 1, margin: 0, paddingLeft: 4, paddingRight: 0, paddingVertical: 0, }, label: { color: 'panelForegroundTertiaryLabel', fontSize: 16, width: 96, }, row: { alignItems: 'center', backgroundColor: 'panelForeground', flexDirection: 'row', paddingHorizontal: 24, paddingVertical: 3, }, }; export default React.memo(function ConnectedThreadSettingsHomeNotifs( props: BaseProps, ) { const styles = useStyles(unboundStyles); const dispatchActionPromise = useDispatchActionPromise(); const callUpdateSubscription = useServerCall(updateSubscription); return ( ); }); diff --git a/native/chat/settings/thread-settings-leave-thread.react.js b/native/chat/settings/thread-settings-leave-thread.react.js index 58974b520..820ab5529 100644 --- a/native/chat/settings/thread-settings-leave-thread.react.js +++ b/native/chat/settings/thread-settings-leave-thread.react.js @@ -1,177 +1,175 @@ // @flow -import type { ThreadInfo, LeaveThreadPayload } from 'lib/types/thread-types'; -import type { LoadingStatus } from 'lib/types/loading-types'; -import type { ViewStyle } from '../../types/styles'; - -import * as React from 'react'; -import { Text, Alert, ActivityIndicator, View } from 'react-native'; import invariant from 'invariant'; - import { leaveThreadActionTypes, leaveThread, } from 'lib/actions/thread-actions'; import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { otherUsersButNoOtherAdmins } from 'lib/selectors/thread-selectors'; import { identifyInvalidatedThreads } from 'lib/shared/thread-utils'; +import type { LoadingStatus } from 'lib/types/loading-types'; +import type { ThreadInfo, LeaveThreadPayload } from 'lib/types/thread-types'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; +import * as React from 'react'; +import { Text, Alert, ActivityIndicator, View } from 'react-native'; import Button from '../../components/button.react'; -import { type Colors, useColors, useStyles } from '../../themes/colors'; +import { clearThreadsActionType } from '../../navigation/action-types'; import { NavContext, type NavContextType, } from '../../navigation/navigation-context'; -import { clearThreadsActionType } from '../../navigation/action-types'; import { useSelector } from '../../redux/redux-utils'; +import { type Colors, useColors, useStyles } from '../../themes/colors'; +import type { ViewStyle } from '../../types/styles'; type BaseProps = {| +threadInfo: ThreadInfo, +buttonStyle: ViewStyle, |}; type Props = {| ...BaseProps, // Redux state +loadingStatus: LoadingStatus, +otherUsersButNoOtherAdmins: boolean, +colors: Colors, +styles: typeof unboundStyles, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +leaveThread: (threadID: string) => Promise, // withNavContext +navContext: ?NavContextType, |}; class ThreadSettingsLeaveThread extends React.PureComponent { render() { const { panelIosHighlightUnderlay, panelForegroundSecondaryLabel, } = this.props.colors; const loadingIndicator = this.props.loadingStatus === 'loading' ? ( ) : null; return ( ); } onPress = () => { if (this.props.otherUsersButNoOtherAdmins) { Alert.alert( 'Need another admin', 'Make somebody else an admin before you leave!', undefined, { cancelable: true }, ); return; } Alert.alert( 'Confirm action', 'Are you sure you want to leave this thread?', [ { text: 'Cancel', style: 'cancel' }, { text: 'OK', onPress: this.onConfirmLeaveThread }, ], { cancelable: true }, ); }; onConfirmLeaveThread = () => { this.props.dispatchActionPromise( leaveThreadActionTypes, this.leaveThread(), ); }; async leaveThread() { const threadID = this.props.threadInfo.id; const { navContext } = this.props; invariant(navContext, 'navContext should exist in leaveThread'); navContext.dispatch({ type: clearThreadsActionType, payload: { threadIDs: [threadID] }, }); try { const result = await this.props.leaveThread(threadID); const invalidated = identifyInvalidatedThreads( result.updatesResult.newUpdates, ); navContext.dispatch({ type: clearThreadsActionType, payload: { threadIDs: [...invalidated] }, }); return result; } catch (e) { Alert.alert('Unknown error', 'Uhh... try again?', undefined, { cancelable: true, }); throw e; } } } const unboundStyles = { button: { flexDirection: 'row', paddingHorizontal: 12, paddingVertical: 10, }, container: { backgroundColor: 'panelForeground', paddingHorizontal: 12, }, text: { color: 'redText', flex: 1, fontSize: 16, }, }; const loadingStatusSelector = createLoadingStatusSelector( leaveThreadActionTypes, ); export default React.memo( function ConnectedThreadSettingsLeaveThread(props: BaseProps) { const loadingStatus = useSelector(loadingStatusSelector); const otherUsersButNoOtherAdminsValue = useSelector( otherUsersButNoOtherAdmins(props.threadInfo.id), ); const colors = useColors(); const styles = useStyles(unboundStyles); const dispatchActionPromise = useDispatchActionPromise(); const callLeaveThread = useServerCall(leaveThread); const navContext = React.useContext(NavContext); return ( ); }, ); diff --git a/native/chat/settings/thread-settings-list-action.react.js b/native/chat/settings/thread-settings-list-action.react.js index 7ac13a906..731ace903 100644 --- a/native/chat/settings/thread-settings-list-action.react.js +++ b/native/chat/settings/thread-settings-list-action.react.js @@ -1,172 +1,170 @@ // @flow -import type { ViewStyle, TextStyle } from '../../types/styles'; -import type { IoniconsGlyphs } from 'react-native-vector-icons/Ionicons'; -import type { AppState } from '../../redux/redux-setup'; - +import { connect } from 'lib/utils/redux-utils'; import * as React from 'react'; import { View, Text, Platform } from 'react-native'; +import type { IoniconsGlyphs } from 'react-native-vector-icons/Ionicons'; import Icon from 'react-native-vector-icons/Ionicons'; -import { connect } from 'lib/utils/redux-utils'; - import Button from '../../components/button.react'; +import type { AppState } from '../../redux/redux-setup'; import { styleSelector } from '../../themes/colors'; +import type { ViewStyle, TextStyle } from '../../types/styles'; type ListActionProps = {| onPress: () => void, text: string, iconName: IoniconsGlyphs, iconSize: number, iconStyle?: TextStyle, buttonStyle?: ViewStyle, styles: typeof styles, |}; function ThreadSettingsListAction(props: ListActionProps) { return ( ); } type SeeMoreProps = {| onPress: () => void, // Redux state styles: typeof styles, |}; function ThreadSettingsSeeMore(props: SeeMoreProps) { return ( ); } type AddMemberProps = {| onPress: () => void, // Redux state styles: typeof styles, |}; function ThreadSettingsAddMember(props: AddMemberProps) { return ( ); } type AddChildThreadProps = {| onPress: () => void, // Redux state styles: typeof styles, |}; function ThreadSettingsAddSubthread(props: AddChildThreadProps) { return ( ); } const styles = { addSubthreadButton: { paddingTop: Platform.OS === 'ios' ? 4 : 1, }, addIcon: { color: '#009900', }, addItemRow: { backgroundColor: 'panelForeground', paddingHorizontal: 12, }, addMemberButton: { paddingTop: Platform.OS === 'ios' ? 4 : 1, }, container: { flex: 1, flexDirection: 'row', paddingHorizontal: 12, paddingVertical: 8, justifyContent: 'center', }, icon: { lineHeight: 20, }, seeMoreButton: { paddingBottom: Platform.OS === 'ios' ? 4 : 2, paddingTop: Platform.OS === 'ios' ? 2 : 0, }, seeMoreContents: { borderColor: 'panelForegroundBorder', borderTopWidth: 1, }, seeMoreIcon: { color: 'link', position: 'absolute', right: 10, top: Platform.OS === 'android' ? 17 : 15, }, seeMoreRow: { backgroundColor: 'panelForeground', paddingHorizontal: 12, }, text: { color: 'link', flex: 1, fontSize: 16, fontStyle: 'italic', }, }; const stylesSelector = styleSelector(styles); const WrappedThreadSettingsSeeMore = connect((state: AppState) => ({ styles: stylesSelector(state), }))(ThreadSettingsSeeMore); const WrappedThreadSettingsAddMember = connect((state: AppState) => ({ styles: stylesSelector(state), }))(ThreadSettingsAddMember); const WrappedThreadSettingsAddSubthread = connect((state: AppState) => ({ styles: stylesSelector(state), }))(ThreadSettingsAddSubthread); export { WrappedThreadSettingsSeeMore as ThreadSettingsSeeMore, WrappedThreadSettingsAddMember as ThreadSettingsAddMember, WrappedThreadSettingsAddSubthread as ThreadSettingsAddSubthread, }; diff --git a/native/chat/settings/thread-settings-member-tooltip-button.react.js b/native/chat/settings/thread-settings-member-tooltip-button.react.js index f234d4c07..3a7201916 100644 --- a/native/chat/settings/thread-settings-member-tooltip-button.react.js +++ b/native/chat/settings/thread-settings-member-tooltip-button.react.js @@ -1,35 +1,34 @@ // @flow -import type { AppNavigationProp } from '../../navigation/app-navigator.react'; - +import PropTypes from 'prop-types'; import * as React from 'react'; import { TouchableOpacity } from 'react-native'; -import PropTypes from 'prop-types'; import PencilIcon from '../../components/pencil-icon.react'; +import type { AppNavigationProp } from '../../navigation/app-navigator.react'; type Props = { +navigation: AppNavigationProp<'ThreadSettingsMemberTooltipModal'>, ... }; class ThreadSettingsMemberTooltipButton extends React.PureComponent { static propTypes = { navigation: PropTypes.shape({ goBackOnce: PropTypes.func.isRequired, }).isRequired, }; render() { return ( ); } onPress = () => { this.props.navigation.goBackOnce(); }; } export default ThreadSettingsMemberTooltipButton; diff --git a/native/chat/settings/thread-settings-member-tooltip-modal.react.js b/native/chat/settings/thread-settings-member-tooltip-modal.react.js index 1b7346458..784682d29 100644 --- a/native/chat/settings/thread-settings-member-tooltip-modal.react.js +++ b/native/chat/settings/thread-settings-member-tooltip-modal.react.js @@ -1,119 +1,118 @@ // @flow -import type { ThreadInfo, RelativeMemberInfo } from 'lib/types/thread-types'; -import type { - DispatchFunctions, - ActionFunc, - BoundServerCall, -} from 'lib/utils/action-utils'; - -import { Alert } from 'react-native'; import invariant from 'invariant'; - -import { stringForUser } from 'lib/shared/user-utils'; import { removeUsersFromThreadActionTypes, removeUsersFromThread, changeThreadMemberRolesActionTypes, changeThreadMemberRoles, } from 'lib/actions/thread-actions'; import { memberIsAdmin, roleIsAdminRole } from 'lib/shared/thread-utils'; +import { stringForUser } from 'lib/shared/user-utils'; +import type { ThreadInfo, RelativeMemberInfo } from 'lib/types/thread-types'; +import type { + DispatchFunctions, + ActionFunc, + BoundServerCall, +} from 'lib/utils/action-utils'; +import { Alert } from 'react-native'; import { createTooltip, type TooltipParams, type TooltipRoute, } from '../../navigation/tooltip.react'; + import ThreadSettingsMemberTooltipButton from './thread-settings-member-tooltip-button.react'; export type ThreadSettingsMemberTooltipModalParams = TooltipParams<{| +memberInfo: RelativeMemberInfo, +threadInfo: ThreadInfo, |}>; function onRemoveUser( route: TooltipRoute<'ThreadSettingsMemberTooltipModal'>, dispatchFunctions: DispatchFunctions, bindServerCall: (serverCall: ActionFunc) => BoundServerCall, ) { const { memberInfo, threadInfo } = route.params; const boundRemoveUsersFromThread = bindServerCall(removeUsersFromThread); const onConfirmRemoveUser = () => { const customKeyName = `${removeUsersFromThreadActionTypes.started}:${memberInfo.id}`; dispatchFunctions.dispatchActionPromise( removeUsersFromThreadActionTypes, boundRemoveUsersFromThread(threadInfo.id, [memberInfo.id]), { customKeyName }, ); }; const userText = stringForUser(memberInfo); Alert.alert( 'Confirm removal', `Are you sure you want to remove ${userText} from this thread?`, [ { text: 'Cancel', style: 'cancel' }, { text: 'OK', onPress: onConfirmRemoveUser }, ], { cancelable: true }, ); } function onToggleAdmin( route: TooltipRoute<'ThreadSettingsMemberTooltipModal'>, dispatchFunctions: DispatchFunctions, bindServerCall: (serverCall: ActionFunc) => BoundServerCall, ) { const { memberInfo, threadInfo } = route.params; const isCurrentlyAdmin = memberIsAdmin(memberInfo, threadInfo); const boundChangeThreadMemberRoles = bindServerCall(changeThreadMemberRoles); const onConfirmMakeAdmin = () => { let newRole = null; for (let roleID in threadInfo.roles) { const role = threadInfo.roles[roleID]; if (isCurrentlyAdmin && role.isDefault) { newRole = role.id; break; } else if (!isCurrentlyAdmin && roleIsAdminRole(role)) { newRole = role.id; break; } } invariant(newRole !== null, 'Could not find new role'); const customKeyName = `${changeThreadMemberRolesActionTypes.started}:${memberInfo.id}`; dispatchFunctions.dispatchActionPromise( changeThreadMemberRolesActionTypes, boundChangeThreadMemberRoles(threadInfo.id, [memberInfo.id], newRole), { customKeyName }, ); }; const userText = stringForUser(memberInfo); const actionClause = isCurrentlyAdmin ? `remove ${userText} as an admin` : `make ${userText} an admin`; Alert.alert( 'Confirm action', `Are you sure you want to ${actionClause} of this thread?`, [ { text: 'Cancel', style: 'cancel' }, { text: 'OK', onPress: onConfirmMakeAdmin }, ], { cancelable: true }, ); } const spec = { entries: [ { id: 'remove_user', text: 'Remove user', onPress: onRemoveUser }, { id: 'remove_admin', text: 'Remove admin', onPress: onToggleAdmin }, { id: 'make_admin', text: 'Make admin', onPress: onToggleAdmin }, ], }; const ThreadSettingsMemberTooltipModal = createTooltip< 'ThreadSettingsMemberTooltipModal', >(ThreadSettingsMemberTooltipButton, spec); export default ThreadSettingsMemberTooltipModal; diff --git a/native/chat/settings/thread-settings-member.react.js b/native/chat/settings/thread-settings-member.react.js index cb96576ce..46dca035c 100644 --- a/native/chat/settings/thread-settings-member.react.js +++ b/native/chat/settings/thread-settings-member.react.js @@ -1,312 +1,311 @@ // @flow +import invariant from 'invariant'; +import { + removeUsersFromThreadActionTypes, + changeThreadMemberRolesActionTypes, +} from 'lib/actions/thread-actions'; +import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; +import { + threadHasPermission, + memberIsAdmin, + memberHasAdminPowers, +} from 'lib/shared/thread-utils'; +import { stringForUser } from 'lib/shared/user-utils'; +import type { LoadingStatus } from 'lib/types/loading-types'; import { type ThreadInfo, type RelativeMemberInfo, threadPermissions, } from 'lib/types/thread-types'; -import type { LoadingStatus } from 'lib/types/loading-types'; -import type { VerticalBounds } from '../../types/layout-types'; -import { ThreadSettingsMemberTooltipModalRouteName } from '../../navigation/route-names'; -import type { ThreadSettingsNavigate } from './thread-settings.react'; - import * as React from 'react'; import { View, Text, Platform, ActivityIndicator, TouchableOpacity, } from 'react-native'; -import invariant from 'invariant'; - -import { - threadHasPermission, - memberIsAdmin, - memberHasAdminPowers, -} from 'lib/shared/thread-utils'; -import { stringForUser } from 'lib/shared/user-utils'; -import { - removeUsersFromThreadActionTypes, - changeThreadMemberRolesActionTypes, -} from 'lib/actions/thread-actions'; -import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import PencilIcon from '../../components/pencil-icon.react'; -import { type Colors, useColors, useStyles } from '../../themes/colors'; +import { SingleLine } from '../../components/single-line.react'; import { type KeyboardState, KeyboardContext, } from '../../keyboard/keyboard-state'; import { OverlayContext, type OverlayContextType, } from '../../navigation/overlay-context'; -import { SingleLine } from '../../components/single-line.react'; +import { ThreadSettingsMemberTooltipModalRouteName } from '../../navigation/route-names'; import { useSelector } from '../../redux/redux-utils'; +import { type Colors, useColors, useStyles } from '../../themes/colors'; +import type { VerticalBounds } from '../../types/layout-types'; + +import type { ThreadSettingsNavigate } from './thread-settings.react'; type BaseProps = {| +memberInfo: RelativeMemberInfo, +threadInfo: ThreadInfo, +canEdit: boolean, +navigate: ThreadSettingsNavigate, +firstListItem: boolean, +lastListItem: boolean, +verticalBounds: ?VerticalBounds, +threadSettingsRouteKey: string, |}; type Props = {| ...BaseProps, // Redux state +removeUserLoadingStatus: LoadingStatus, +changeRoleLoadingStatus: LoadingStatus, +colors: Colors, +styles: typeof unboundStyles, // withKeyboardState +keyboardState: ?KeyboardState, // withOverlayContext +overlayContext: ?OverlayContextType, |}; class ThreadSettingsMember extends React.PureComponent { editButton: ?React.ElementRef; visibleEntryIDs() { const role = this.props.memberInfo.role; if (!this.props.canEdit || !role) { return []; } const canRemoveMembers = threadHasPermission( this.props.threadInfo, threadPermissions.REMOVE_MEMBERS, ); const canChangeRoles = threadHasPermission( this.props.threadInfo, threadPermissions.CHANGE_ROLE, ); const result = []; if ( canRemoveMembers && !this.props.memberInfo.isViewer && (canChangeRoles || (this.props.threadInfo.roles[role] && this.props.threadInfo.roles[role].isDefault)) ) { result.push('remove_user'); } if (canChangeRoles && this.props.memberInfo.username) { result.push( memberIsAdmin(this.props.memberInfo, this.props.threadInfo) ? 'remove_admin' : 'make_admin', ); } return result; } render() { const userText = stringForUser(this.props.memberInfo); let userInfo = null; if (this.props.memberInfo.username) { userInfo = ( {userText} ); } else { userInfo = ( {userText} ); } let editButton = null; if ( this.props.removeUserLoadingStatus === 'loading' || this.props.changeRoleLoadingStatus === 'loading' ) { editButton = ( ); } else if (this.visibleEntryIDs().length !== 0) { editButton = ( ); } let roleInfo = null; if (memberIsAdmin(this.props.memberInfo, this.props.threadInfo)) { roleInfo = ( admin ); } else if (memberHasAdminPowers(this.props.memberInfo)) { roleInfo = ( parent admin ); } const firstItem = this.props.firstListItem ? null : this.props.styles.topBorder; const lastItem = this.props.lastListItem ? this.props.styles.lastInnerContainer : null; return ( {userInfo} {editButton} {roleInfo} ); } editButtonRef = (editButton: ?React.ElementRef) => { this.editButton = editButton; }; onEditButtonLayout = () => {}; onPressEdit = () => { if (this.dismissKeyboardIfShowing()) { return; } const { editButton, props: { verticalBounds }, } = this; if (!editButton || !verticalBounds) { return; } const { overlayContext } = this.props; invariant( overlayContext, 'ThreadSettingsMember should have OverlayContext', ); overlayContext.setScrollBlockingModalStatus('open'); editButton.measure((x, y, width, height, pageX, pageY) => { const coordinates = { x: pageX, y: pageY, width, height }; this.props.navigate({ name: ThreadSettingsMemberTooltipModalRouteName, params: { presentedFrom: this.props.threadSettingsRouteKey, initialCoordinates: coordinates, verticalBounds, visibleEntryIDs: this.visibleEntryIDs(), memberInfo: this.props.memberInfo, threadInfo: this.props.threadInfo, }, }); }); }; dismissKeyboardIfShowing = () => { const { keyboardState } = this.props; return !!(keyboardState && keyboardState.dismissKeyboardIfShowing()); }; } const unboundStyles = { anonymous: { color: 'panelForegroundTertiaryLabel', fontStyle: 'italic', }, container: { backgroundColor: 'panelForeground', flex: 1, paddingHorizontal: 12, }, editButton: { paddingLeft: 10, }, topBorder: { borderColor: 'panelForegroundBorder', borderTopWidth: 1, }, innerContainer: { flex: 1, paddingHorizontal: 12, paddingVertical: 8, }, lastInnerContainer: { paddingBottom: Platform.OS === 'ios' ? 12 : 10, }, role: { color: 'panelForegroundTertiaryLabel', flex: 1, fontSize: 14, paddingTop: 4, }, row: { flex: 1, flexDirection: 'row', }, username: { color: 'panelForegroundSecondaryLabel', flex: 1, fontSize: 16, lineHeight: 20, }, }; export default React.memo(function ConnectedThreadSettingsMember( props: BaseProps, ) { const memberID = props.memberInfo.id; const removeUserLoadingStatus = useSelector((state) => createLoadingStatusSelector( removeUsersFromThreadActionTypes, `${removeUsersFromThreadActionTypes.started}:${memberID}`, )(state), ); const changeRoleLoadingStatus = useSelector((state) => createLoadingStatusSelector( changeThreadMemberRolesActionTypes, `${changeThreadMemberRolesActionTypes.started}:${memberID}`, )(state), ); const colors = useColors(); const styles = useStyles(unboundStyles); const keyboardState = React.useContext(KeyboardContext); const overlayContext = React.useContext(OverlayContext); return ( ); }); diff --git a/native/chat/settings/thread-settings-name.react.js b/native/chat/settings/thread-settings-name.react.js index 873097921..313fe6731 100644 --- a/native/chat/settings/thread-settings-name.react.js +++ b/native/chat/settings/thread-settings-name.react.js @@ -1,249 +1,248 @@ // @flow +import invariant from 'invariant'; +import { + changeThreadSettingsActionTypes, + changeThreadSettings, +} from 'lib/actions/thread-actions'; +import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; +import type { LoadingStatus } from 'lib/types/loading-types'; +import { loadingStatusPropType } from 'lib/types/loading-types'; import { type ThreadInfo, threadInfoPropType, type ChangeThreadSettingsPayload, type UpdateThreadRequest, } from 'lib/types/thread-types'; import type { DispatchActionPromise } from 'lib/utils/action-utils'; -import type { LoadingStatus } from 'lib/types/loading-types'; -import { loadingStatusPropType } from 'lib/types/loading-types'; -import type { AppState } from '../../redux/redux-setup'; -import type { - LayoutEvent, - ContentSizeChangeEvent, -} from '../../types/react-native'; - +import { connect } from 'lib/utils/redux-utils'; +import PropTypes from 'prop-types'; import * as React from 'react'; import { Text, Alert, ActivityIndicator, TextInput, View } from 'react-native'; -import PropTypes from 'prop-types'; -import invariant from 'invariant'; - -import { connect } from 'lib/utils/redux-utils'; -import { - changeThreadSettingsActionTypes, - changeThreadSettings, -} from 'lib/actions/thread-actions'; -import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import EditSettingButton from '../../components/edit-setting-button.react'; -import SaveSettingButton from './save-setting-button.react'; +import type { AppState } from '../../redux/redux-setup'; import { type Colors, colorsPropType, colorsSelector, styleSelector, } from '../../themes/colors'; +import type { + LayoutEvent, + ContentSizeChangeEvent, +} from '../../types/react-native'; + +import SaveSettingButton from './save-setting-button.react'; type Props = {| threadInfo: ThreadInfo, nameEditValue: ?string, setNameEditValue: (value: ?string, callback?: () => void) => void, nameTextHeight: ?number, setNameTextHeight: (number: number) => void, canChangeSettings: boolean, // Redux state loadingStatus: LoadingStatus, colors: Colors, styles: typeof styles, // Redux dispatch functions dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs changeThreadSettings: ( update: UpdateThreadRequest, ) => Promise, |}; class ThreadSettingsName extends React.PureComponent { static propTypes = { threadInfo: threadInfoPropType.isRequired, nameEditValue: PropTypes.string, setNameEditValue: PropTypes.func.isRequired, nameTextHeight: PropTypes.number, setNameTextHeight: PropTypes.func.isRequired, canChangeSettings: PropTypes.bool.isRequired, loadingStatus: loadingStatusPropType.isRequired, colors: colorsPropType.isRequired, styles: PropTypes.objectOf(PropTypes.object).isRequired, dispatchActionPromise: PropTypes.func.isRequired, changeThreadSettings: PropTypes.func.isRequired, }; textInput: ?React.ElementRef; render() { return ( Name {this.renderContent()} ); } renderContent() { if ( this.props.nameEditValue === null || this.props.nameEditValue === undefined ) { return ( {this.props.threadInfo.uiName} ); } let button; if (this.props.loadingStatus !== 'loading') { button = ; } else { button = ( ); } const textInputStyle = {}; if ( this.props.nameTextHeight !== undefined && this.props.nameTextHeight !== null ) { textInputStyle.height = this.props.nameTextHeight; } return ( {button} ); } textInputRef = (textInput: ?React.ElementRef) => { this.textInput = textInput; }; onLayoutText = (event: LayoutEvent) => { this.props.setNameTextHeight(event.nativeEvent.layout.height); }; onTextInputContentSizeChange = (event: ContentSizeChangeEvent) => { this.props.setNameTextHeight(event.nativeEvent.contentSize.height); }; threadEditName() { return this.props.threadInfo.name ? this.props.threadInfo.name : ''; } onPressEdit = () => { this.props.setNameEditValue(this.threadEditName()); }; onSubmit = () => { invariant( this.props.nameEditValue !== null && this.props.nameEditValue !== undefined, 'should be set', ); const name = this.props.nameEditValue.trim(); if (name === this.threadEditName()) { this.props.setNameEditValue(null); return; } const editNamePromise = this.editName(name); this.props.dispatchActionPromise( changeThreadSettingsActionTypes, editNamePromise, { customKeyName: `${changeThreadSettingsActionTypes.started}:name` }, ); editNamePromise.then(() => { this.props.setNameEditValue(null); }); }; async editName(newName: string) { try { return await this.props.changeThreadSettings({ threadID: this.props.threadInfo.id, changes: { name: newName }, }); } catch (e) { Alert.alert( 'Unknown error', 'Uhh... try again?', [{ text: 'OK', onPress: this.onErrorAcknowledged }], { cancelable: false }, ); throw e; } } onErrorAcknowledged = () => { this.props.setNameEditValue(this.threadEditName(), () => { invariant(this.textInput, 'textInput should be set'); this.textInput.focus(); }); }; } const styles = { currentValue: { color: 'panelForegroundSecondaryLabel', flex: 1, fontFamily: 'Arial', fontSize: 16, margin: 0, paddingLeft: 4, paddingRight: 0, paddingVertical: 0, borderBottomColor: 'transparent', }, label: { color: 'panelForegroundTertiaryLabel', fontSize: 16, width: 96, }, row: { backgroundColor: 'panelForeground', flexDirection: 'row', paddingHorizontal: 24, paddingVertical: 8, }, }; const stylesSelector = styleSelector(styles); const loadingStatusSelector = createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:name`, ); export default connect( (state: AppState) => ({ loadingStatus: loadingStatusSelector(state), colors: colorsSelector(state), styles: stylesSelector(state), }), { changeThreadSettings }, )(ThreadSettingsName); diff --git a/native/chat/settings/thread-settings-parent.react.js b/native/chat/settings/thread-settings-parent.react.js index 313610536..3038c96c4 100644 --- a/native/chat/settings/thread-settings-parent.react.js +++ b/native/chat/settings/thread-settings-parent.react.js @@ -1,146 +1,145 @@ // @flow -import { type ThreadInfo, threadInfoPropType } from 'lib/types/thread-types'; -import type { AppState } from '../../redux/redux-setup'; -import type { ThreadSettingsNavigate } from './thread-settings.react'; - -import * as React from 'react'; -import { Text, View, Platform } from 'react-native'; -import PropTypes from 'prop-types'; import invariant from 'invariant'; - import { threadInfoSelector } from 'lib/selectors/thread-selectors'; +import { type ThreadInfo, threadInfoPropType } from 'lib/types/thread-types'; import { connect } from 'lib/utils/redux-utils'; +import PropTypes from 'prop-types'; +import * as React from 'react'; +import { Text, View, Platform } from 'react-native'; import Button from '../../components/button.react'; +import { SingleLine } from '../../components/single-line.react'; import { MessageListRouteName } from '../../navigation/route-names'; +import type { AppState } from '../../redux/redux-setup'; import { styleSelector } from '../../themes/colors'; -import { SingleLine } from '../../components/single-line.react'; + +import type { ThreadSettingsNavigate } from './thread-settings.react'; type Props = {| threadInfo: ThreadInfo, navigate: ThreadSettingsNavigate, // Redux state parentThreadInfo?: ?ThreadInfo, styles: typeof styles, |}; class ThreadSettingsParent extends React.PureComponent { static propTypes = { threadInfo: threadInfoPropType.isRequired, navigate: PropTypes.func.isRequired, parentThreadInfo: threadInfoPropType, styles: PropTypes.objectOf(PropTypes.object).isRequired, }; render() { let parent; if (this.props.parentThreadInfo) { parent = ( ); } else if (this.props.threadInfo.parentThreadID) { parent = ( Secret parent ); } else { parent = ( No parent ); } return ( Parent {parent} ); } onPressParentThread = () => { const threadInfo = this.props.parentThreadInfo; invariant(threadInfo, 'should be set'); this.props.navigate({ name: MessageListRouteName, params: { threadInfo }, key: `${MessageListRouteName}${threadInfo.id}`, }); }; } const styles = { currentValue: { flex: 1, paddingLeft: 4, paddingTop: Platform.OS === 'ios' ? 5 : 4, }, currentValueText: { color: 'panelForegroundSecondaryLabel', fontFamily: 'Arial', fontSize: 16, margin: 0, paddingRight: 0, }, label: { color: 'panelForegroundTertiaryLabel', fontSize: 16, paddingVertical: 4, width: 96, }, noParent: { fontStyle: 'italic', paddingLeft: 2, }, parentThreadLink: { color: 'link', }, row: { backgroundColor: 'panelForeground', flexDirection: 'row', paddingHorizontal: 24, }, }; const stylesSelector = styleSelector(styles); export default connect( (state: AppState, ownProps: { threadInfo: ThreadInfo }) => { const parsedThreadInfos = threadInfoSelector(state); const parentThreadInfo: ?ThreadInfo = ownProps.threadInfo.parentThreadID ? parsedThreadInfos[ownProps.threadInfo.parentThreadID] : null; return { parentThreadInfo, styles: stylesSelector(state), }; }, )(ThreadSettingsParent); diff --git a/native/chat/settings/thread-settings-promote-sidebar.react.js b/native/chat/settings/thread-settings-promote-sidebar.react.js index b95983d05..0e3746aa9 100644 --- a/native/chat/settings/thread-settings-promote-sidebar.react.js +++ b/native/chat/settings/thread-settings-promote-sidebar.react.js @@ -1,134 +1,132 @@ // @flow +import { + changeThreadSettingsActionTypes, + changeThreadSettings, +} from 'lib/actions/thread-actions'; +import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; +import type { LoadingStatus } from 'lib/types/loading-types'; import { type ThreadInfo, type UpdateThreadRequest, type ChangeThreadSettingsPayload, threadTypes, } from 'lib/types/thread-types'; -import type { LoadingStatus } from 'lib/types/loading-types'; -import type { ViewStyle } from '../../types/styles'; - -import * as React from 'react'; -import { Text, Alert, ActivityIndicator, View } from 'react-native'; - -import { - changeThreadSettingsActionTypes, - changeThreadSettings, -} from 'lib/actions/thread-actions'; -import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { type DispatchActionPromise, useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; +import * as React from 'react'; +import { Text, Alert, ActivityIndicator, View } from 'react-native'; import Button from '../../components/button.react'; -import { type Colors, useColors, useStyles } from '../../themes/colors'; import { useSelector } from '../../redux/redux-utils'; +import { type Colors, useColors, useStyles } from '../../themes/colors'; +import type { ViewStyle } from '../../types/styles'; type BaseProps = {| +threadInfo: ThreadInfo, +buttonStyle: ViewStyle, |}; type Props = {| ...BaseProps, // Redux state +loadingStatus: LoadingStatus, +colors: Colors, +styles: typeof unboundStyles, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +changeThreadSettings: ( request: UpdateThreadRequest, ) => Promise, |}; class ThreadSettingsPromoteSubthread extends React.PureComponent { render() { const { panelIosHighlightUnderlay, panelForegroundSecondaryLabel, } = this.props.colors; const loadingIndicator = this.props.loadingStatus === 'loading' ? ( ) : null; return ( ); } onPress = () => { this.props.dispatchActionPromise( changeThreadSettingsActionTypes, this.changeThreadSettings(), ); }; async changeThreadSettings() { const threadID = this.props.threadInfo.id; try { return await this.props.changeThreadSettings({ threadID, changes: { type: threadTypes.CHAT_NESTED_OPEN }, }); } catch (e) { Alert.alert('Unknown error', 'Uhh... try again?', undefined, { cancelable: true, }); throw e; } } } const unboundStyles = { button: { flexDirection: 'row', paddingHorizontal: 12, paddingVertical: 10, }, container: { backgroundColor: 'panelForeground', paddingHorizontal: 12, }, text: { color: 'panelForegroundSecondaryLabel', flex: 1, fontSize: 16, }, }; const loadingStatusSelector = createLoadingStatusSelector( changeThreadSettingsActionTypes, ); export default React.memo( function ConnectedThreadSettingsPromoteSubthread(props: BaseProps) { const loadingStatus = useSelector(loadingStatusSelector); const colors = useColors(); const styles = useStyles(unboundStyles); const dispatchActionPromise = useDispatchActionPromise(); const callChangeThreadSettings = useServerCall(changeThreadSettings); return ( ); }, ); diff --git a/native/chat/settings/thread-settings-push-notifs.react.js b/native/chat/settings/thread-settings-push-notifs.react.js index 17e840354..b1086cdc1 100644 --- a/native/chat/settings/thread-settings-push-notifs.react.js +++ b/native/chat/settings/thread-settings-push-notifs.react.js @@ -1,122 +1,120 @@ // @flow -import { type ThreadInfo, threadInfoPropType } from 'lib/types/thread-types'; -import type { DispatchActionPromise } from 'lib/utils/action-utils'; -import type { - SubscriptionUpdateRequest, - SubscriptionUpdateResult, -} from 'lib/types/subscription-types'; - -import * as React from 'react'; -import { Text, View, Switch } from 'react-native'; -import PropTypes from 'prop-types'; - import { updateSubscriptionActionTypes, updateSubscription, } from 'lib/actions/user-actions'; +import type { + SubscriptionUpdateRequest, + SubscriptionUpdateResult, +} from 'lib/types/subscription-types'; +import { type ThreadInfo, threadInfoPropType } from 'lib/types/thread-types'; +import type { DispatchActionPromise } from 'lib/utils/action-utils'; import { useServerCall, useDispatchActionPromise, } from 'lib/utils/action-utils'; +import PropTypes from 'prop-types'; +import * as React from 'react'; +import { Text, View, Switch } from 'react-native'; import { useStyles } from '../../themes/colors'; type BaseProps = {| +threadInfo: ThreadInfo, |}; type Props = {| ...BaseProps, // Redux state +styles: typeof unboundStyles, // Redux dispatch functions +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +updateSubscription: ( subscriptionUpdate: SubscriptionUpdateRequest, ) => Promise, |}; type State = {| +currentValue: boolean, |}; class ThreadSettingsPushNotifs extends React.PureComponent { static propTypes = { threadInfo: threadInfoPropType.isRequired, styles: PropTypes.objectOf(PropTypes.object).isRequired, dispatchActionPromise: PropTypes.func.isRequired, updateSubscription: PropTypes.func.isRequired, }; constructor(props: Props) { super(props); this.state = { currentValue: props.threadInfo.currentUser.subscription.pushNotifs, }; } render() { return ( Push notifs ); } onValueChange = (value: boolean) => { this.setState({ currentValue: value }); this.props.dispatchActionPromise( updateSubscriptionActionTypes, this.props.updateSubscription({ threadID: this.props.threadInfo.id, updatedFields: { pushNotifs: value, }, }), ); }; } const unboundStyles = { currentValue: { alignItems: 'flex-end', flex: 1, margin: 0, paddingLeft: 4, paddingRight: 0, paddingVertical: 0, }, label: { color: 'panelForegroundTertiaryLabel', fontSize: 16, width: 96, }, row: { alignItems: 'center', backgroundColor: 'panelForeground', flexDirection: 'row', paddingHorizontal: 24, paddingVertical: 3, }, }; export default React.memo(function ConnectedThreadSettingsPushNotifs( props: BaseProps, ) { const styles = useStyles(unboundStyles); const dispatchActionPromise = useDispatchActionPromise(); const callUpdateSubscription = useServerCall(updateSubscription); return ( ); }); diff --git a/native/chat/settings/thread-settings-visibility.react.js b/native/chat/settings/thread-settings-visibility.react.js index 01885d1de..9fe5d8829 100644 --- a/native/chat/settings/thread-settings-visibility.react.js +++ b/native/chat/settings/thread-settings-visibility.react.js @@ -1,51 +1,49 @@ // @flow import type { ThreadInfo } from 'lib/types/thread-types'; -import type { AppState } from '../../redux/redux-setup'; -import type { Colors } from '../../themes/colors'; - +import { connect } from 'lib/utils/redux-utils'; import * as React from 'react'; import { Text, View } from 'react-native'; -import { connect } from 'lib/utils/redux-utils'; - import ThreadVisibility from '../../components/thread-visibility.react'; +import type { AppState } from '../../redux/redux-setup'; +import type { Colors } from '../../themes/colors'; import { colorsSelector, styleSelector } from '../../themes/colors'; type Props = {| threadInfo: ThreadInfo, // Redux state colors: Colors, styles: typeof styles, |}; function ThreadSettingsVisibility(props: Props) { return ( Visibility ); } const styles = { label: { color: 'panelForegroundTertiaryLabel', fontSize: 16, width: 96, }, row: { backgroundColor: 'panelForeground', flexDirection: 'row', paddingHorizontal: 24, paddingVertical: 8, }, }; const stylesSelector = styleSelector(styles); export default connect((state: AppState) => ({ colors: colorsSelector(state), styles: stylesSelector(state), }))(ThreadSettingsVisibility); diff --git a/native/chat/settings/thread-settings.react.js b/native/chat/settings/thread-settings.react.js index a69c5a4ee..b8216a4bc 100644 --- a/native/chat/settings/thread-settings.react.js +++ b/native/chat/settings/thread-settings.react.js @@ -1,1074 +1,1073 @@ // @flow -import { - type ThreadInfo, - type RelativeMemberInfo, - threadPermissions, - threadTypes, -} from 'lib/types/thread-types'; -import type { AppState } from '../../redux/redux-setup'; -import type { CategoryType } from './thread-settings-category.react'; -import type { VerticalBounds } from '../../types/layout-types'; -import type { ChatNavigationProp } from '../chat.react'; -import type { TabNavigationProp } from '../../navigation/app-navigator.react'; -import type { NavigationRoute } from '../../navigation/route-names'; -import type { ViewStyle } from '../../types/styles'; - -import * as React from 'react'; -import { View, FlatList, Platform } from 'react-native'; import invariant from 'invariant'; -import { createSelector } from 'reselect'; - -import { relativeMemberInfoSelectorForMembersOfThread } from 'lib/selectors/user-selectors'; -import { - threadInfoSelector, - childThreadInfos, -} from 'lib/selectors/thread-selectors'; -import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import { changeThreadSettingsActionTypes, leaveThreadActionTypes, removeUsersFromThreadActionTypes, changeThreadMemberRolesActionTypes, } from 'lib/actions/thread-actions'; +import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors'; +import { + threadInfoSelector, + childThreadInfos, +} from 'lib/selectors/thread-selectors'; +import { relativeMemberInfoSelectorForMembersOfThread } from 'lib/selectors/user-selectors'; import { threadHasPermission, viewerIsMember, threadInChatList, } from 'lib/shared/thread-utils'; import threadWatcher from 'lib/shared/thread-watcher'; +import { + type ThreadInfo, + type RelativeMemberInfo, + threadPermissions, + threadTypes, +} from 'lib/types/thread-types'; +import * as React from 'react'; +import { View, FlatList, Platform } from 'react-native'; +import { createSelector } from 'reselect'; import { - ThreadSettingsCategoryHeader, - ThreadSettingsCategoryFooter, -} from './thread-settings-category.react'; -import ThreadSettingsMember from './thread-settings-member.react'; + type KeyboardState, + KeyboardContext, +} from '../../keyboard/keyboard-state'; +import type { TabNavigationProp } from '../../navigation/app-navigator.react'; import { - ThreadSettingsSeeMore, - ThreadSettingsAddMember, - ThreadSettingsAddSubthread, -} from './thread-settings-list-action.react'; -import ThreadSettingsChildThread from './thread-settings-child-thread.react'; -import ThreadSettingsName from './thread-settings-name.react'; -import ThreadSettingsColor from './thread-settings-color.react'; -import ThreadSettingsDescription from './thread-settings-description.react'; -import ThreadSettingsParent from './thread-settings-parent.react'; -import ThreadSettingsVisibility from './thread-settings-visibility.react'; -import ThreadSettingsPushNotifs from './thread-settings-push-notifs.react'; -import ThreadSettingsHomeNotifs from './thread-settings-home-notifs.react'; -import ThreadSettingsLeaveThread from './thread-settings-leave-thread.react'; -import ThreadSettingsDeleteThread from './thread-settings-delete-thread.react'; -import ThreadSettingsPromoteSidebar from './thread-settings-promote-sidebar.react'; + OverlayContext, + type OverlayContextType, +} from '../../navigation/overlay-context'; +import type { NavigationRoute } from '../../navigation/route-names'; import { AddUsersModalRouteName, ComposeSubthreadModalRouteName, } from '../../navigation/route-names'; +import type { AppState } from '../../redux/redux-setup'; +import { useSelector } from '../../redux/redux-utils'; import { useStyles, type IndicatorStyle, useIndicatorStyle, } from '../../themes/colors'; +import type { VerticalBounds } from '../../types/layout-types'; +import type { ViewStyle } from '../../types/styles'; +import type { ChatNavigationProp } from '../chat.react'; + +import type { CategoryType } from './thread-settings-category.react'; import { - OverlayContext, - type OverlayContextType, -} from '../../navigation/overlay-context'; + ThreadSettingsCategoryHeader, + ThreadSettingsCategoryFooter, +} from './thread-settings-category.react'; +import ThreadSettingsChildThread from './thread-settings-child-thread.react'; +import ThreadSettingsColor from './thread-settings-color.react'; +import ThreadSettingsDeleteThread from './thread-settings-delete-thread.react'; +import ThreadSettingsDescription from './thread-settings-description.react'; +import ThreadSettingsHomeNotifs from './thread-settings-home-notifs.react'; +import ThreadSettingsLeaveThread from './thread-settings-leave-thread.react'; import { - type KeyboardState, - KeyboardContext, -} from '../../keyboard/keyboard-state'; -import { useSelector } from '../../redux/redux-utils'; + ThreadSettingsSeeMore, + ThreadSettingsAddMember, + ThreadSettingsAddSubthread, +} from './thread-settings-list-action.react'; +import ThreadSettingsMember from './thread-settings-member.react'; +import ThreadSettingsName from './thread-settings-name.react'; +import ThreadSettingsParent from './thread-settings-parent.react'; +import ThreadSettingsPromoteSidebar from './thread-settings-promote-sidebar.react'; +import ThreadSettingsPushNotifs from './thread-settings-push-notifs.react'; +import ThreadSettingsVisibility from './thread-settings-visibility.react'; const itemPageLength = 5; export type ThreadSettingsParams = {| threadInfo: ThreadInfo, |}; export type ThreadSettingsNavigate = $PropertyType< ChatNavigationProp<'ThreadSettings'>, 'navigate', >; type ChatSettingsItem = | {| +itemType: 'header', +key: string, +title: string, +categoryType: CategoryType, |} | {| +itemType: 'footer', +key: string, +categoryType: CategoryType, |} | {| +itemType: 'name', +key: string, +threadInfo: ThreadInfo, +nameEditValue: ?string, +nameTextHeight: ?number, +canChangeSettings: boolean, |} | {| +itemType: 'color', +key: string, +threadInfo: ThreadInfo, +colorEditValue: string, +canChangeSettings: boolean, +navigate: ThreadSettingsNavigate, +threadSettingsRouteKey: string, |} | {| +itemType: 'description', +key: string, +threadInfo: ThreadInfo, +descriptionEditValue: ?string, +descriptionTextHeight: ?number, +canChangeSettings: boolean, |} | {| +itemType: 'parent', +key: string, +threadInfo: ThreadInfo, +navigate: ThreadSettingsNavigate, |} | {| +itemType: 'visibility', +key: string, +threadInfo: ThreadInfo, |} | {| +itemType: 'pushNotifs', +key: string, +threadInfo: ThreadInfo, |} | {| +itemType: 'homeNotifs', +key: string, +threadInfo: ThreadInfo, |} | {| +itemType: 'seeMore', +key: string, +onPress: () => void, |} | {| +itemType: 'childThread', +key: string, +threadInfo: ThreadInfo, +navigate: ThreadSettingsNavigate, +firstListItem: boolean, +lastListItem: boolean, |} | {| +itemType: 'addSubthread', +key: string, |} | {| +itemType: 'member', +key: string, +memberInfo: RelativeMemberInfo, +threadInfo: ThreadInfo, +canEdit: boolean, +navigate: ThreadSettingsNavigate, +firstListItem: boolean, +lastListItem: boolean, +verticalBounds: ?VerticalBounds, +threadSettingsRouteKey: string, |} | {| +itemType: 'addMember', +key: string, |} | {| +itemType: 'promoteSidebar' | 'leaveThread' | 'deleteThread', +key: string, +threadInfo: ThreadInfo, +navigate: ThreadSettingsNavigate, +buttonStyle: ViewStyle, |}; type BaseProps = {| +navigation: ChatNavigationProp<'ThreadSettings'>, +route: NavigationRoute<'ThreadSettings'>, |}; type Props = {| ...BaseProps, // Redux state +threadInfo: ?ThreadInfo, +threadMembers: $ReadOnlyArray, +childThreadInfos: ?$ReadOnlyArray, +somethingIsSaving: boolean, +styles: typeof unboundStyles, +indicatorStyle: IndicatorStyle, // withOverlayContext +overlayContext: ?OverlayContextType, // withKeyboardState +keyboardState: ?KeyboardState, |}; type State = {| +numMembersShowing: number, +numSubthreadsShowing: number, +numSidebarsShowing: number, +nameEditValue: ?string, +descriptionEditValue: ?string, +nameTextHeight: ?number, +descriptionTextHeight: ?number, +colorEditValue: string, +verticalBounds: ?VerticalBounds, |}; type PropsAndState = {| ...Props, ...State |}; class ThreadSettings extends React.PureComponent { flatListContainer: ?React.ElementRef; constructor(props: Props) { super(props); const threadInfo = props.threadInfo; invariant(threadInfo, 'ThreadInfo should exist when ThreadSettings opened'); this.state = { numMembersShowing: itemPageLength, numSubthreadsShowing: itemPageLength, numSidebarsShowing: itemPageLength, nameEditValue: null, descriptionEditValue: null, nameTextHeight: null, descriptionTextHeight: null, colorEditValue: threadInfo.color, verticalBounds: null, }; } static getThreadInfo(props: { threadInfo: ?ThreadInfo, route: NavigationRoute<'ThreadSettings'>, ... }): ThreadInfo { const { threadInfo } = props; if (threadInfo) { return threadInfo; } return props.route.params.threadInfo; } static scrollDisabled(props: Props) { const { overlayContext } = props; invariant(overlayContext, 'ThreadSettings should have OverlayContext'); return overlayContext.scrollBlockingModalStatus !== 'closed'; } componentDidMount() { const threadInfo = ThreadSettings.getThreadInfo(this.props); if (!threadInChatList(threadInfo)) { threadWatcher.watchID(threadInfo.id); } const tabNavigation: ?TabNavigationProp< 'Chat', > = this.props.navigation.dangerouslyGetParent(); invariant(tabNavigation, 'ChatNavigator should be within TabNavigator'); tabNavigation.addListener('tabPress', this.onTabPress); } componentWillUnmount() { const threadInfo = ThreadSettings.getThreadInfo(this.props); if (!threadInChatList(threadInfo)) { threadWatcher.removeID(threadInfo.id); } const tabNavigation: ?TabNavigationProp< 'Chat', > = this.props.navigation.dangerouslyGetParent(); invariant(tabNavigation, 'ChatNavigator should be within TabNavigator'); tabNavigation.removeListener('tabPress', this.onTabPress); } onTabPress = () => { if (this.props.navigation.isFocused() && !this.props.somethingIsSaving) { this.props.navigation.popToTop(); } }; componentDidUpdate(prevProps: Props) { const oldReduxThreadInfo = prevProps.threadInfo; const newReduxThreadInfo = this.props.threadInfo; if (newReduxThreadInfo && newReduxThreadInfo !== oldReduxThreadInfo) { this.props.navigation.setParams({ threadInfo: newReduxThreadInfo }); } const oldNavThreadInfo = ThreadSettings.getThreadInfo(prevProps); const newNavThreadInfo = ThreadSettings.getThreadInfo(this.props); if (oldNavThreadInfo.id !== newNavThreadInfo.id) { if (!threadInChatList(oldNavThreadInfo)) { threadWatcher.removeID(oldNavThreadInfo.id); } if (!threadInChatList(newNavThreadInfo)) { threadWatcher.watchID(newNavThreadInfo.id); } } if ( newNavThreadInfo.color !== oldNavThreadInfo.color && this.state.colorEditValue === oldNavThreadInfo.color ) { this.setState({ colorEditValue: newNavThreadInfo.color }); } const scrollIsDisabled = ThreadSettings.scrollDisabled(this.props); const scrollWasDisabled = ThreadSettings.scrollDisabled(prevProps); if (!scrollWasDisabled && scrollIsDisabled) { this.props.navigation.setOptions({ gestureEnabled: false }); } else if (scrollWasDisabled && !scrollIsDisabled) { this.props.navigation.setOptions({ gestureEnabled: true }); } } threadBasicsListDataSelector = createSelector( (propsAndState: PropsAndState) => ThreadSettings.getThreadInfo(propsAndState), (propsAndState: PropsAndState) => propsAndState.nameEditValue, (propsAndState: PropsAndState) => propsAndState.nameTextHeight, (propsAndState: PropsAndState) => propsAndState.colorEditValue, (propsAndState: PropsAndState) => propsAndState.descriptionEditValue, (propsAndState: PropsAndState) => propsAndState.descriptionTextHeight, (propsAndState: PropsAndState) => !propsAndState.somethingIsSaving, (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.route.key, ( threadInfo: ThreadInfo, nameEditValue: ?string, nameTextHeight: ?number, colorEditValue: string, descriptionEditValue: ?string, descriptionTextHeight: ?number, canStartEditing: boolean, navigate: ThreadSettingsNavigate, routeKey: string, ) => { const canEditThread = threadHasPermission( threadInfo, threadPermissions.EDIT_THREAD, ); const canChangeSettings = canEditThread && canStartEditing; const listData: ChatSettingsItem[] = []; listData.push({ itemType: 'header', key: 'basicsHeader', title: 'Basics', categoryType: 'full', }); listData.push({ itemType: 'name', key: 'name', threadInfo, nameEditValue, nameTextHeight, canChangeSettings, }); listData.push({ itemType: 'color', key: 'color', threadInfo, colorEditValue, canChangeSettings, navigate, threadSettingsRouteKey: routeKey, }); listData.push({ itemType: 'footer', key: 'basicsFooter', categoryType: 'full', }); if ( (descriptionEditValue !== null && descriptionEditValue !== undefined) || threadInfo.description || canEditThread ) { listData.push({ itemType: 'description', key: 'description', threadInfo, descriptionEditValue, descriptionTextHeight, canChangeSettings, }); } const isMember = viewerIsMember(threadInfo); if (isMember) { listData.push({ itemType: 'header', key: 'subscriptionHeader', title: 'Subscription', categoryType: 'full', }); listData.push({ itemType: 'pushNotifs', key: 'pushNotifs', threadInfo, }); listData.push({ itemType: 'homeNotifs', key: 'homeNotifs', threadInfo, }); listData.push({ itemType: 'footer', key: 'subscriptionFooter', categoryType: 'full', }); } listData.push({ itemType: 'header', key: 'privacyHeader', title: 'Privacy', categoryType: 'full', }); listData.push({ itemType: 'parent', key: 'parent', threadInfo, navigate, }); listData.push({ itemType: 'visibility', key: 'visibility', threadInfo, }); listData.push({ itemType: 'footer', key: 'privacyFooter', categoryType: 'full', }); return listData; }, ); subthreadsListDataSelector = createSelector( (propsAndState: PropsAndState) => ThreadSettings.getThreadInfo(propsAndState), (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.childThreadInfos, (propsAndState: PropsAndState) => propsAndState.numSubthreadsShowing, ( threadInfo: ThreadInfo, navigate: ThreadSettingsNavigate, childThreads: ?$ReadOnlyArray, numSubthreadsShowing: number, ) => { const listData: ChatSettingsItem[] = []; const subthreads = childThreads?.filter( (childThreadInfo) => childThreadInfo.type !== threadTypes.SIDEBAR, ) ?? []; const canCreateSubthreads = threadHasPermission( threadInfo, threadPermissions.CREATE_SUBTHREADS, ); if (subthreads.length === 0 && !canCreateSubthreads) { return listData; } listData.push({ itemType: 'header', key: 'subthreadHeader', title: 'Subthreads', categoryType: 'unpadded', }); if (canCreateSubthreads) { listData.push({ itemType: 'addSubthread', key: 'addSubthread', }); } const numItems = Math.min(numSubthreadsShowing, subthreads.length); for (let i = 0; i < numItems; i++) { const subthreadInfo = subthreads[i]; listData.push({ itemType: 'childThread', key: `childThread${subthreadInfo.id}`, threadInfo: subthreadInfo, navigate, firstListItem: i === 0 && !canCreateSubthreads, lastListItem: i === numItems - 1 && numItems === subthreads.length, }); } if (numItems < subthreads.length) { listData.push({ itemType: 'seeMore', key: 'seeMoreSubthreads', onPress: this.onPressSeeMoreSubthreads, }); } listData.push({ itemType: 'footer', key: 'subthreadFooter', categoryType: 'unpadded', }); return listData; }, ); sidebarsListDataSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.childThreadInfos, (propsAndState: PropsAndState) => propsAndState.numSidebarsShowing, ( navigate: ThreadSettingsNavigate, childThreads: ?$ReadOnlyArray, numSidebarsShowing: number, ) => { const listData: ChatSettingsItem[] = []; const sidebars = childThreads?.filter( (childThreadInfo) => childThreadInfo.type === threadTypes.SIDEBAR, ) ?? []; if (sidebars.length === 0) { return listData; } listData.push({ itemType: 'header', key: 'sidebarHeader', title: 'Sidebars', categoryType: 'unpadded', }); const numItems = Math.min(numSidebarsShowing, sidebars.length); for (let i = 0; i < numItems; i++) { const sidebarInfo = sidebars[i]; listData.push({ itemType: 'childThread', key: `childThread${sidebarInfo.id}`, threadInfo: sidebarInfo, navigate, firstListItem: i === 0, lastListItem: i === numItems - 1 && numItems === sidebars.length, }); } if (numItems < sidebars.length) { listData.push({ itemType: 'seeMore', key: 'seeMoreSidebars', onPress: this.onPressSeeMoreSidebars, }); } listData.push({ itemType: 'footer', key: 'sidebarFooter', categoryType: 'unpadded', }); return listData; }, ); threadMembersListDataSelector = createSelector( (propsAndState: PropsAndState) => ThreadSettings.getThreadInfo(propsAndState), (propsAndState: PropsAndState) => !propsAndState.somethingIsSaving, (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.route.key, (propsAndState: PropsAndState) => propsAndState.threadMembers, (propsAndState: PropsAndState) => propsAndState.numMembersShowing, (propsAndState: PropsAndState) => propsAndState.verticalBounds, ( threadInfo: ThreadInfo, canStartEditing: boolean, navigate: ThreadSettingsNavigate, routeKey: string, threadMembers: $ReadOnlyArray, numMembersShowing: number, verticalBounds: ?VerticalBounds, ) => { const listData: ChatSettingsItem[] = []; const canAddMembers = threadHasPermission( threadInfo, threadPermissions.ADD_MEMBERS, ); if (threadMembers.length === 0 && !canAddMembers) { return listData; } listData.push({ itemType: 'header', key: 'memberHeader', title: 'Members', categoryType: 'unpadded', }); if (canAddMembers) { listData.push({ itemType: 'addMember', key: 'addMember', }); } const numItems = Math.min(numMembersShowing, threadMembers.length); for (let i = 0; i < numItems; i++) { const memberInfo = threadMembers[i]; listData.push({ itemType: 'member', key: `member${memberInfo.id}`, memberInfo, threadInfo, canEdit: canStartEditing, navigate, firstListItem: i === 0 && !canAddMembers, lastListItem: i === numItems - 1 && numItems === threadMembers.length, verticalBounds, threadSettingsRouteKey: routeKey, }); } if (numItems < threadMembers.length) { listData.push({ itemType: 'seeMore', key: 'seeMoreMembers', onPress: this.onPressSeeMoreMembers, }); } listData.push({ itemType: 'footer', key: 'memberFooter', categoryType: 'unpadded', }); return listData; }, ); actionsListDataSelector = createSelector( (propsAndState: PropsAndState) => ThreadSettings.getThreadInfo(propsAndState), (propsAndState: PropsAndState) => propsAndState.navigation.navigate, (propsAndState: PropsAndState) => propsAndState.styles, ( threadInfo: ThreadInfo, navigate: ThreadSettingsNavigate, styles: typeof unboundStyles, ) => { const buttons = []; const canChangeThreadType = threadHasPermission( threadInfo, threadPermissions.EDIT_PERMISSIONS, ); const canPromoteSidebar = threadInfo.type === threadTypes.SIDEBAR && canChangeThreadType; if (canPromoteSidebar) { buttons.push({ itemType: 'promoteSidebar', key: 'promoteSidebar', threadInfo, navigate, }); } const canLeaveThread = threadHasPermission( threadInfo, threadPermissions.LEAVE_THREAD, ); if (viewerIsMember(threadInfo) && canLeaveThread) { buttons.push({ itemType: 'leaveThread', key: 'leaveThread', threadInfo, navigate, }); } const canDeleteThread = threadHasPermission( threadInfo, threadPermissions.DELETE_THREAD, ); if (canDeleteThread) { buttons.push({ itemType: 'deleteThread', key: 'deleteThread', threadInfo, navigate, }); } const listData: ChatSettingsItem[] = []; if (buttons.length === 0) { return listData; } listData.push({ itemType: 'header', key: 'actionsHeader', title: 'Actions', categoryType: 'unpadded', }); for (let i = 0; i < buttons.length; i++) { listData.push({ ...buttons[i], buttonStyle: [ i === 0 ? null : styles.nonTopButton, i === buttons.length - 1 ? styles.lastButton : null, ], }); } listData.push({ itemType: 'footer', key: 'actionsFooter', categoryType: 'unpadded', }); return listData; }, ); listDataSelector = createSelector( this.threadBasicsListDataSelector, this.subthreadsListDataSelector, this.sidebarsListDataSelector, this.threadMembersListDataSelector, this.actionsListDataSelector, ( threadBasicsListData: ChatSettingsItem[], subthreadsListData: ChatSettingsItem[], sidebarsListData: ChatSettingsItem[], threadMembersListData: ChatSettingsItem[], actionsListData: ChatSettingsItem[], ) => [ ...threadBasicsListData, ...subthreadsListData, ...sidebarsListData, ...threadMembersListData, ...actionsListData, ], ); get listData() { return this.listDataSelector({ ...this.props, ...this.state }); } render() { return ( ); } flatListContainerRef = ( flatListContainer: ?React.ElementRef, ) => { this.flatListContainer = flatListContainer; }; onFlatListContainerLayout = () => { const { flatListContainer } = this; if (!flatListContainer) { return; } const { keyboardState } = this.props; if (!keyboardState || keyboardState.keyboardShowing) { return; } flatListContainer.measure((x, y, width, height, pageX, pageY) => { if ( height === null || height === undefined || pageY === null || pageY === undefined ) { return; } this.setState({ verticalBounds: { height, y: pageY } }); }); }; renderItem = (row: { item: ChatSettingsItem }) => { const item = row.item; if (item.itemType === 'header') { return ( ); } else if (item.itemType === 'footer') { return ; } else if (item.itemType === 'name') { return ( ); } else if (item.itemType === 'color') { return ( ); } else if (item.itemType === 'description') { return ( ); } else if (item.itemType === 'parent') { return ( ); } else if (item.itemType === 'visibility') { return ; } else if (item.itemType === 'pushNotifs') { return ; } else if (item.itemType === 'homeNotifs') { return ; } else if (item.itemType === 'seeMore') { return ; } else if (item.itemType === 'childThread') { return ( ); } else if (item.itemType === 'addSubthread') { return ( ); } else if (item.itemType === 'member') { return ( ); } else if (item.itemType === 'addMember') { return ; } else if (item.itemType === 'leaveThread') { return ( ); } else if (item.itemType === 'deleteThread') { return ( ); } else if (item.itemType === 'promoteSidebar') { return ( ); } else { invariant(false, `unexpected ThreadSettings item type ${item.itemType}`); } }; setNameEditValue = (value: ?string, callback?: () => void) => { this.setState({ nameEditValue: value }, callback); }; setNameTextHeight = (height: number) => { this.setState({ nameTextHeight: height }); }; setColorEditValue = (color: string) => { this.setState({ colorEditValue: color }); }; setDescriptionEditValue = (value: ?string, callback?: () => void) => { this.setState({ descriptionEditValue: value }, callback); }; setDescriptionTextHeight = (height: number) => { this.setState({ descriptionTextHeight: height }); }; onPressComposeSubthread = () => { const threadInfo = ThreadSettings.getThreadInfo(this.props); this.props.navigation.navigate(ComposeSubthreadModalRouteName, { presentedFrom: this.props.route.key, threadInfo, }); }; onPressAddMember = () => { const threadInfo = ThreadSettings.getThreadInfo(this.props); this.props.navigation.navigate(AddUsersModalRouteName, { presentedFrom: this.props.route.key, threadInfo, }); }; onPressSeeMoreMembers = () => { this.setState((prevState) => ({ numMembersShowing: prevState.numMembersShowing + itemPageLength, })); }; onPressSeeMoreSubthreads = () => { this.setState((prevState) => ({ numSubthreadsShowing: prevState.numSubthreadsShowing + itemPageLength, })); }; onPressSeeMoreSidebars = () => { this.setState((prevState) => ({ numSidebarsShowing: prevState.numSidebarsShowing + itemPageLength, })); }; } const unboundStyles = { container: { backgroundColor: 'panelBackground', flex: 1, }, flatList: { paddingVertical: 16, }, nonTopButton: { borderColor: 'panelForegroundBorder', borderTopWidth: 1, }, lastButton: { paddingBottom: Platform.OS === 'ios' ? 14 : 12, }, }; const editNameLoadingStatusSelector = createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:name`, ); const editColorLoadingStatusSelector = createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:color`, ); const editDescriptionLoadingStatusSelector = createLoadingStatusSelector( changeThreadSettingsActionTypes, `${changeThreadSettingsActionTypes.started}:description`, ); const leaveThreadLoadingStatusSelector = createLoadingStatusSelector( leaveThreadActionTypes, ); const somethingIsSaving = ( state: AppState, threadMembers: $ReadOnlyArray, ) => { if ( editNameLoadingStatusSelector(state) === 'loading' || editColorLoadingStatusSelector(state) === 'loading' || editDescriptionLoadingStatusSelector(state) === 'loading' || leaveThreadLoadingStatusSelector(state) === 'loading' ) { return true; } for (let threadMember of threadMembers) { const removeUserLoadingStatus = createLoadingStatusSelector( removeUsersFromThreadActionTypes, `${removeUsersFromThreadActionTypes.started}:${threadMember.id}`, )(state); if (removeUserLoadingStatus === 'loading') { return true; } const changeRoleLoadingStatus = createLoadingStatusSelector( changeThreadMemberRolesActionTypes, `${changeThreadMemberRolesActionTypes.started}:${threadMember.id}`, )(state); if (changeRoleLoadingStatus === 'loading') { return true; } } return false; }; export default React.memo(function ConnectedThreadSettings( props: BaseProps, ) { const threadID = props.route.params.threadInfo.id; const threadInfo = useSelector( (state) => threadInfoSelector(state)[threadID], ); const threadMembers = useSelector( relativeMemberInfoSelectorForMembersOfThread(threadID), ); const boundChildThreadInfos = useSelector( (state) => childThreadInfos(state)[threadID], ); const boundSomethingIsSaving = useSelector((state) => somethingIsSaving(state, threadMembers), ); const styles = useStyles(unboundStyles); const indicatorStyle = useIndicatorStyle(); const overlayContext = React.useContext(OverlayContext); const keyboardState = React.useContext(KeyboardContext); return ( ); }); diff --git a/native/chat/sidebar-item.react.js b/native/chat/sidebar-item.react.js index 4e3fc5ae1..02a560d85 100644 --- a/native/chat/sidebar-item.react.js +++ b/native/chat/sidebar-item.react.js @@ -1,85 +1,83 @@ // @flow import type { ThreadInfo, SidebarInfo } from 'lib/types/thread-types'; -import type { ViewStyle } from '../types/styles'; - +import { shortAbsoluteDate } from 'lib/utils/date-utils'; import * as React from 'react'; import { Text } from 'react-native'; import Icon from 'react-native-vector-icons/Entypo'; -import { shortAbsoluteDate } from 'lib/utils/date-utils'; - -import { useColors, useStyles } from '../themes/colors'; import Button from '../components/button.react'; import { SingleLine } from '../components/single-line.react'; +import { useColors, useStyles } from '../themes/colors'; +import type { ViewStyle } from '../types/styles'; type Props = {| +sidebarInfo: SidebarInfo, +onPressItem: (threadInfo: ThreadInfo) => void, +style?: ?ViewStyle, |}; function SidebarItem(props: Props) { const { lastUpdatedTime } = props.sidebarInfo; const lastActivity = shortAbsoluteDate(lastUpdatedTime); const { threadInfo } = props.sidebarInfo; const styles = useStyles(unboundStyles); const unreadStyle = threadInfo.currentUser.unread ? styles.unread : null; const { onPressItem } = props; const onPress = React.useCallback(() => onPressItem(threadInfo), [ threadInfo, onPressItem, ]); const colors = useColors(); return ( ); } const unboundStyles = { unread: { color: 'listForegroundLabel', fontWeight: 'bold', }, sidebar: { height: 30, flexDirection: 'row', display: 'flex', marginLeft: 25, marginRight: 10, alignItems: 'center', }, icon: { paddingLeft: 5, color: 'listForegroundSecondaryLabel', width: 35, }, name: { color: 'listForegroundSecondaryLabel', flex: 1, fontSize: 16, paddingLeft: 5, paddingBottom: 2, }, lastActivity: { color: 'listForegroundTertiaryLabel', fontSize: 14, marginLeft: 10, }, }; export default SidebarItem; diff --git a/native/chat/sidebar-list-modal.react.js b/native/chat/sidebar-list-modal.react.js index 99733363e..a6983f0af 100644 --- a/native/chat/sidebar-list-modal.react.js +++ b/native/chat/sidebar-list-modal.react.js @@ -1,163 +1,162 @@ // @flow -import type { ThreadInfo, SidebarInfo } from 'lib/types/thread-types'; -import type { RootNavigationProp } from '../navigation/root-navigator.react'; -import type { NavigationRoute } from '../navigation/route-names'; - -import * as React from 'react'; -import { TextInput, FlatList, StyleSheet } from 'react-native'; - import { sidebarInfoSelector } from 'lib/selectors/thread-selectors'; import SearchIndex from 'lib/shared/search-index'; import { threadSearchText } from 'lib/shared/thread-utils'; +import type { ThreadInfo, SidebarInfo } from 'lib/types/thread-types'; +import * as React from 'react'; +import { TextInput, FlatList, StyleSheet } from 'react-native'; -import { useSelector } from '../redux/redux-utils'; import Modal from '../components/modal.react'; import Search from '../components/search.react'; -import { useIndicatorStyle } from '../themes/colors'; +import type { RootNavigationProp } from '../navigation/root-navigator.react'; +import type { NavigationRoute } from '../navigation/route-names'; import { MessageListRouteName } from '../navigation/route-names'; -import SidebarItem from './sidebar-item.react'; +import { useSelector } from '../redux/redux-utils'; +import { useIndicatorStyle } from '../themes/colors'; import { waitForModalInputFocus } from '../utils/timers'; +import SidebarItem from './sidebar-item.react'; + export type SidebarListModalParams = {| +threadInfo: ThreadInfo, |}; function keyExtractor(sidebarInfo: SidebarInfo) { return sidebarInfo.threadInfo.id; } function getItemLayout(data: ?$ReadOnlyArray, index: number) { return { length: 24, offset: 24 * index, index }; } type Props = {| +navigation: RootNavigationProp<'SidebarListModal'>, +route: NavigationRoute<'SidebarListModal'>, |}; function SidebarListModal(props: Props) { const threadID = props.route.params.threadInfo.id; const sidebarInfos = useSelector( (state) => sidebarInfoSelector(state)[threadID] ?? [], ); const [searchState, setSearchState] = React.useState({ text: '', results: new Set(), }); const listData = React.useMemo(() => { if (!searchState.text) { return sidebarInfos; } return sidebarInfos.filter(({ threadInfo }) => searchState.results.has(threadInfo.id), ); }, [sidebarInfos, searchState]); const userInfos = useSelector((state) => state.userStore.userInfos); const searchIndex = React.useMemo(() => { const index = new SearchIndex(); for (const sidebarInfo of sidebarInfos) { const { threadInfo } = sidebarInfo; index.addEntry(threadInfo.id, threadSearchText(threadInfo, userInfos)); } return index; }, [sidebarInfos, userInfos]); React.useEffect(() => { setSearchState((curState) => ({ ...curState, results: new Set(searchIndex.getSearchResults(curState.text)), })); }, [searchIndex]); const onChangeSearchText = React.useCallback( (searchText: string) => setSearchState({ text: searchText, results: new Set(searchIndex.getSearchResults(searchText)), }), [searchIndex], ); const searchTextInputRef = React.useRef(); const setSearchTextInputRef = React.useCallback( async (textInput: ?React.ElementRef) => { searchTextInputRef.current = textInput; if (!textInput) { return; } await waitForModalInputFocus(); if (searchTextInputRef.current) { searchTextInputRef.current.focus(); } }, [], ); const { navigation } = props; const { navigate } = navigation; const onPressItem = React.useCallback( (threadInfo: ThreadInfo) => { setSearchState({ text: '', results: new Set(), }); if (searchTextInputRef.current) { searchTextInputRef.current.blur(); } navigate({ name: MessageListRouteName, params: { threadInfo }, key: `${MessageListRouteName}${threadInfo.id}`, }); }, [navigate], ); const renderItem = React.useCallback( (row: { item: SidebarInfo, ... }) => { return ( ); }, [onPressItem], ); const indicatorStyle = useIndicatorStyle(); return ( ); } const styles = StyleSheet.create({ search: { marginBottom: 8, }, sidebar: { marginLeft: 0, marginRight: 5, }, }); export default SidebarListModal; diff --git a/native/chat/swipeable-message.react.js b/native/chat/swipeable-message.react.js index b6aebb6f6..5136de659 100644 --- a/native/chat/swipeable-message.react.js +++ b/native/chat/swipeable-message.react.js @@ -1,211 +1,210 @@ // @flow -import type { ViewStyle } from '../types/styles'; - +import { GestureHandlerRefContext } from '@react-navigation/stack'; import * as React from 'react'; import { View, Platform } from 'react-native'; -import FontAwesomeIcon from 'react-native-vector-icons/FontAwesome'; -import { TapticFeedback } from 'react-native-in-app-message'; -import Animated from 'react-native-reanimated'; import { PanGestureHandler, State as GestureState, } from 'react-native-gesture-handler'; -import { GestureHandlerRefContext } from '@react-navigation/stack'; +import { TapticFeedback } from 'react-native-in-app-message'; +import Animated from 'react-native-reanimated'; +import FontAwesomeIcon from 'react-native-vector-icons/FontAwesome'; import { useColors, useStyles } from '../themes/colors'; +import type { ViewStyle } from '../types/styles'; import { dividePastDistance, runSpring } from '../utils/animation-utils'; /* eslint-disable import/no-named-as-default-member */ const { Value, Clock, block, event, Extrapolate, set, call, cond, not, and, greaterOrEq, eq, add, abs, max, min, stopClock, interpolate, SpringUtils, } = Animated; /* eslint-enable import/no-named-as-default-member */ const threshold = 40; const springConfig = { ...SpringUtils.makeConfigFromBouncinessAndSpeed({ ...SpringUtils.makeDefaultConfig(), bounciness: 10, speed: 8, }), overshootClamping: true, }; type Props = {| +onSwipeableWillOpen: () => void, +isViewer: boolean, +messageBoxStyle: ViewStyle, +children: React.Node, |}; function SwipeableMessage(props: Props) { const { isViewer, onSwipeableWillOpen } = props; const onPassThreshold = React.useCallback(() => { if (Platform.OS === 'ios') { TapticFeedback.impact(); } }, []); const { swipeEvent, transformMessageBoxStyle, transformReplyStyle, } = React.useMemo(() => { const swipeX = new Value(0); const swipeState = new Value(-1); const swipeVelocityX = new Value(0); const innerSwipeEvent = event([ { nativeEvent: { translationX: swipeX, state: swipeState, velocityX: swipeVelocityX, }, }, ]); const curX = new Value(0); const prevSwipeState = new Value(-1); const resetClock = new Clock(); const isActive = eq(swipeState, GestureState.ACTIVE); const baseActiveTranslation = isViewer ? min(add(curX, swipeX), 0) : max(add(curX, swipeX), 0); const activeTranslation = dividePastDistance( baseActiveTranslation, threshold, 2, ); const pastThreshold = greaterOrEq(abs(activeTranslation), threshold); const prevPastThreshold = new Value(0); const translateX = block([ cond(and(eq(prevSwipeState, GestureState.ACTIVE), not(isActive)), [ set(curX, activeTranslation), cond(pastThreshold, call([], onSwipeableWillOpen)), ]), set(prevSwipeState, swipeState), cond( and(isActive, pastThreshold, not(prevPastThreshold)), call([], onPassThreshold), ), set(prevPastThreshold, pastThreshold), cond( isActive, [stopClock(resetClock), activeTranslation], [ cond( eq(curX, 0), stopClock(resetClock), set( curX, runSpring(resetClock, curX, 0, true, springConfig, { velocity: swipeVelocityX, }), ), ), curX, ], ), ]); const innerTransformMessageBoxStyle = { transform: [{ translateX }], }; const translateReplyIcon = interpolate(translateX, { inputRange: isViewer ? [-1 * threshold, 0] : [0, threshold], outputRange: isViewer ? [-23, -23 + threshold] : [0 - threshold, 0], extrapolate: Extrapolate.CLAMP, }); const replyIconOpacity = interpolate(translateX, { inputRange: isViewer ? [-1 * threshold, -25] : [25, threshold], outputRange: isViewer ? [1, 0] : [0, 1], extrapolate: Extrapolate.CLAMP, }); const innerTransformReplyStyle = { transform: [ { translateX: translateReplyIcon, }, ], opacity: replyIconOpacity, }; return { swipeEvent: innerSwipeEvent, transformMessageBoxStyle: innerTransformMessageBoxStyle, transformReplyStyle: innerTransformReplyStyle, }; }, [isViewer, onSwipeableWillOpen, onPassThreshold]); const iconPosition = isViewer ? { right: 0 } : { left: 0 }; const { messageBoxStyle, children } = props; const colors = useColors(); const styles = useStyles(unboundStyles); const reactNavGestureHandlerRef = React.useContext(GestureHandlerRefContext); const waitFor = reactNavGestureHandlerRef ?? undefined; return ( {children} ); } const unboundStyles = { icon: { justifyContent: 'center', position: 'absolute', }, iconBackground: { alignItems: 'center', backgroundColor: 'listChatBubble', borderRadius: 30, height: 30, justifyContent: 'center', width: 30, }, }; export default SwipeableMessage; diff --git a/native/chat/swipeable-thread.react.js b/native/chat/swipeable-thread.react.js index 86ca2a24f..0f29cbb25 100644 --- a/native/chat/swipeable-thread.react.js +++ b/native/chat/swipeable-thread.react.js @@ -1,118 +1,117 @@ // @flow -import type { ThreadInfo } from 'lib/types/thread-types'; +import { useNavigation } from '@react-navigation/native'; +import { + setThreadUnreadStatus, + setThreadUnreadStatusActionTypes, +} from 'lib/actions/activity-actions'; import type { SetThreadUnreadStatusPayload, SetThreadUnreadStatusRequest, } from 'lib/types/activity-types'; +import type { ThreadInfo } from 'lib/types/thread-types'; import { useDispatchActionPromise, useServerCall, } from 'lib/utils/action-utils'; -import { - setThreadUnreadStatus, - setThreadUnreadStatusActionTypes, -} from 'lib/actions/activity-actions'; - import * as React from 'react'; -import { useNavigation } from '@react-navigation/native'; import MaterialIcon from 'react-native-vector-icons/MaterialCommunityIcons'; import Swipeable from '../components/swipeable'; import { useColors } from '../themes/colors'; type Props = {| +threadInfo: ThreadInfo, +mostRecentNonLocalMessage: ?string, +onSwipeableWillOpen: (threadInfo: ThreadInfo) => void, +currentlyOpenedSwipeableId?: string, +iconSize: number, +children: React.Node, |}; function SwipeableThread(props: Props) { const swipeable = React.useRef(); const navigation = useNavigation(); React.useEffect(() => { return navigation.addListener('blur', () => { if (swipeable.current) { swipeable.current.close(); } }); }, [navigation, swipeable]); const { threadInfo, currentlyOpenedSwipeableId } = props; React.useEffect(() => { if (swipeable.current && threadInfo.id !== currentlyOpenedSwipeableId) { swipeable.current.close(); } }, [currentlyOpenedSwipeableId, swipeable, threadInfo.id]); const { onSwipeableWillOpen } = props; const onSwipeableRightWillOpen = React.useCallback(() => { onSwipeableWillOpen(threadInfo); }, [onSwipeableWillOpen, threadInfo]); const colors = useColors(); const { mostRecentNonLocalMessage, iconSize } = props; const updateUnreadStatus: ( request: SetThreadUnreadStatusRequest, ) => Promise = useServerCall( setThreadUnreadStatus, ); const dispatchActionPromise = useDispatchActionPromise(); const swipeableActions = React.useMemo(() => { const isUnread = threadInfo.currentUser.unread; const toggleUnreadStatus = () => { const request = { unread: !isUnread, threadID: threadInfo.id, latestMessage: mostRecentNonLocalMessage, }; dispatchActionPromise( setThreadUnreadStatusActionTypes, updateUnreadStatus(request), undefined, { threadID: threadInfo.id, unread: !isUnread, }, ); if (swipeable.current) { swipeable.current.close(); } }; return [ { key: 'action1', onPress: toggleUnreadStatus, color: isUnread ? colors.redButton : colors.greenButton, content: ( ), }, ]; }, [ colors, threadInfo, mostRecentNonLocalMessage, iconSize, updateUnreadStatus, dispatchActionPromise, ]); return ( {props.children} ); } export default SwipeableThread; diff --git a/native/chat/text-message-tooltip-button.react.js b/native/chat/text-message-tooltip-button.react.js index 14616a7d7..4cf4d9739 100644 --- a/native/chat/text-message-tooltip-button.react.js +++ b/native/chat/text-message-tooltip-button.react.js @@ -1,56 +1,56 @@ // @flow -import type { AppNavigationProp } from '../navigation/app-navigator.react'; -import type { TooltipRoute } from '../navigation/tooltip.react'; - import * as React from 'react'; import Animated from 'react-native-reanimated'; +import type { AppNavigationProp } from '../navigation/app-navigator.react'; +import type { TooltipRoute } from '../navigation/tooltip.react'; +import { useSelector } from '../redux/redux-utils'; + import { InnerTextMessage } from './inner-text-message.react'; import { MessageHeader } from './message-header.react'; -import { useSelector } from '../redux/redux-utils'; import { MessageListContext, useMessageListContext, } from './message-list-types'; /* eslint-disable import/no-named-as-default-member */ const { Value } = Animated; /* eslint-enable import/no-named-as-default-member */ type Props = {| +navigation: AppNavigationProp<'TextMessageTooltipModal'>, +route: TooltipRoute<'TextMessageTooltipModal'>, +progress: Value, |}; function TextMessageTooltipButton(props: Props) { const { progress } = props; const windowWidth = useSelector((state) => state.dimensions.width); const { initialCoordinates } = props.route.params; const headerStyle = React.useMemo(() => { const bottom = initialCoordinates.height; return { opacity: progress, position: 'absolute', left: -initialCoordinates.x, width: windowWidth, bottom, }; }, [progress, windowWidth, initialCoordinates]); const { item } = props.route.params; const threadID = item.threadInfo.id; const messageListContext = useMessageListContext(threadID); const { navigation } = props; return ( ); } export default TextMessageTooltipButton; diff --git a/native/chat/text-message-tooltip-modal.react.js b/native/chat/text-message-tooltip-modal.react.js index 440dbf4e1..5c0a71aa8 100644 --- a/native/chat/text-message-tooltip-modal.react.js +++ b/native/chat/text-message-tooltip-modal.react.js @@ -1,63 +1,62 @@ // @flow -import type { ChatTextMessageInfoItemWithHeight } from './text-message.react'; +import Clipboard from '@react-native-community/clipboard'; +import invariant from 'invariant'; +import { createMessageReply } from 'lib/shared/message-utils'; import type { DispatchFunctions, ActionFunc, BoundServerCall, } from 'lib/utils/action-utils'; -import type { InputState } from '../input/input-state'; - -import Clipboard from '@react-native-community/clipboard'; -import invariant from 'invariant'; - -import { createMessageReply } from 'lib/shared/message-utils'; +import type { InputState } from '../input/input-state'; +import { displayActionResultModal } from '../navigation/action-result-modal'; import { createTooltip, tooltipHeight, type TooltipParams, type TooltipRoute, } from '../navigation/tooltip.react'; + import TextMessageTooltipButton from './text-message-tooltip-button.react'; -import { displayActionResultModal } from '../navigation/action-result-modal'; +import type { ChatTextMessageInfoItemWithHeight } from './text-message.react'; export type TextMessageTooltipModalParams = TooltipParams<{| +item: ChatTextMessageInfoItemWithHeight, |}>; const confirmCopy = () => displayActionResultModal('copied!'); function onPressCopy(route: TooltipRoute<'TextMessageTooltipModal'>) { Clipboard.setString(route.params.item.messageInfo.text); setTimeout(confirmCopy); } function onPressReply( route: TooltipRoute<'TextMessageTooltipModal'>, dispatchFunctions: DispatchFunctions, bindServerCall: (serverCall: ActionFunc) => BoundServerCall, inputState: ?InputState, ) { invariant( inputState, 'inputState should be set in TextMessageTooltipModal.onPressReply', ); inputState.addReply(createMessageReply(route.params.item.messageInfo.text)); } const spec = { entries: [ { id: 'copy', text: 'Copy', onPress: onPressCopy }, { id: 'reply', text: 'Reply', onPress: onPressReply }, ], }; const TextMessageTooltipModal = createTooltip<'TextMessageTooltipModal'>( TextMessageTooltipButton, spec, ); const textMessageTooltipHeight = tooltipHeight(spec.entries.length); export { TextMessageTooltipModal, textMessageTooltipHeight }; diff --git a/native/chat/text-message.react.js b/native/chat/text-message.react.js index 6ce1dc09b..163fad87a 100644 --- a/native/chat/text-message.react.js +++ b/native/chat/text-message.react.js @@ -1,210 +1,209 @@ // @flow +import invariant from 'invariant'; +import { messageKey } from 'lib/shared/message-utils'; import type { TextMessageInfo, LocalMessageInfo, } from 'lib/types/message-types'; import type { ThreadInfo } from 'lib/types/thread-types'; -import type { VerticalBounds } from '../types/layout-types'; -import type { ChatNavigationProp } from './chat.react'; -import type { NavigationRoute } from '../navigation/route-names'; - import * as React from 'react'; import { View } from 'react-native'; -import invariant from 'invariant'; -import { messageKey } from 'lib/shared/message-utils'; - -import { InnerTextMessage } from './inner-text-message.react'; -import { textMessageTooltipHeight } from './text-message-tooltip-modal.react'; -import { TextMessageTooltipModalRouteName } from '../navigation/route-names'; -import { ComposedMessage, clusterEndHeight } from './composed-message.react'; -import { authorNameHeight } from './message-header.react'; -import { failedSendHeight } from './failed-send.react'; -import textMessageSendFailed from './text-message-send-failed'; import { type KeyboardState, KeyboardContext, } from '../keyboard/keyboard-state'; +import { MarkdownLinkContext } from '../markdown/markdown-link-context'; import { OverlayContext, type OverlayContextType, } from '../navigation/overlay-context'; -import { MarkdownLinkContext } from '../markdown/markdown-link-context'; +import type { NavigationRoute } from '../navigation/route-names'; +import { TextMessageTooltipModalRouteName } from '../navigation/route-names'; +import type { VerticalBounds } from '../types/layout-types'; + +import type { ChatNavigationProp } from './chat.react'; +import { ComposedMessage, clusterEndHeight } from './composed-message.react'; +import { failedSendHeight } from './failed-send.react'; +import { InnerTextMessage } from './inner-text-message.react'; +import { authorNameHeight } from './message-header.react'; +import textMessageSendFailed from './text-message-send-failed'; +import { textMessageTooltipHeight } from './text-message-tooltip-modal.react'; export type ChatTextMessageInfoItemWithHeight = {| itemType: 'message', messageShapeType: 'text', messageInfo: TextMessageInfo, localMessageInfo: ?LocalMessageInfo, threadInfo: ThreadInfo, startsConversation: boolean, startsCluster: boolean, endsCluster: boolean, contentHeight: number, |}; function textMessageItemHeight(item: ChatTextMessageInfoItemWithHeight) { const { messageInfo, contentHeight, startsCluster, endsCluster } = item; const { isViewer } = messageInfo.creator; let height = 5 + contentHeight; // 5 from marginBottom in ComposedMessage if (!isViewer && startsCluster) { height += authorNameHeight; } if (endsCluster) { height += clusterEndHeight; } if (textMessageSendFailed(item)) { height += failedSendHeight; } return height; } type BaseProps = {| ...React.ElementConfig, +item: ChatTextMessageInfoItemWithHeight, +navigation: ChatNavigationProp<'MessageList'>, +route: NavigationRoute<'MessageList'>, +focused: boolean, +toggleFocus: (messageKey: string) => void, +verticalBounds: ?VerticalBounds, |}; type Props = {| ...BaseProps, // withKeyboardState +keyboardState: ?KeyboardState, // withOverlayContext +overlayContext: ?OverlayContextType, // MarkdownLinkContext +linkPressActive: boolean, |}; class TextMessage extends React.PureComponent { message: ?React.ElementRef; render() { const { item, navigation, route, focused, toggleFocus, verticalBounds, keyboardState, overlayContext, linkPressActive, ...viewProps } = this.props; return ( ); } messageRef = (message: ?React.ElementRef) => { this.message = message; }; onPress = () => { if (this.dismissKeyboardIfShowing()) { return; } const { message, props: { verticalBounds, linkPressActive }, } = this; if (!message || !verticalBounds || linkPressActive) { return; } const { focused, toggleFocus, item } = this.props; if (!focused) { toggleFocus(messageKey(item.messageInfo)); } const { overlayContext } = this.props; invariant(overlayContext, 'TextMessage should have OverlayContext'); overlayContext.setScrollBlockingModalStatus('open'); message.measure((x, y, width, height, pageX, pageY) => { const coordinates = { x: pageX, y: pageY, width, height }; const messageTop = pageY; const messageBottom = pageY + height; const boundsTop = verticalBounds.y; const boundsBottom = verticalBounds.y + verticalBounds.height; const belowMargin = 20; const belowSpace = textMessageTooltipHeight + belowMargin; const { isViewer } = item.messageInfo.creator; const aboveMargin = isViewer ? 30 : 50; const aboveSpace = textMessageTooltipHeight + aboveMargin; let location = 'below', margin = belowMargin; if ( messageBottom + belowSpace > boundsBottom && messageTop - aboveSpace > boundsTop ) { location = 'above'; margin = aboveMargin; } this.props.navigation.navigate({ name: TextMessageTooltipModalRouteName, params: { presentedFrom: this.props.route.key, initialCoordinates: coordinates, verticalBounds, location, margin, item, }, }); }); }; dismissKeyboardIfShowing = () => { const { keyboardState } = this.props; return !!(keyboardState && keyboardState.dismissKeyboardIfShowing()); }; } const ConnectedTextMessage = React.memo( function ConnectedTextMessage(props: BaseProps) { const keyboardState = React.useContext(KeyboardContext); const overlayContext = React.useContext(OverlayContext); const [linkPressActive, setLinkPressActive] = React.useState(false); const markdownLinkContext = React.useMemo( () => ({ setLinkPressActive, }), [setLinkPressActive], ); return ( ); }, ); export { ConnectedTextMessage as TextMessage, textMessageItemHeight }; diff --git a/native/chat/thread-screen-pruner.react.js b/native/chat/thread-screen-pruner.react.js index d816882b7..e3de0f2a7 100644 --- a/native/chat/thread-screen-pruner.react.js +++ b/native/chat/thread-screen-pruner.react.js @@ -1,83 +1,82 @@ // @flow -import type { AppState } from '../redux/redux-setup'; - +import { threadIsPending } from 'lib/shared/thread-utils'; import * as React from 'react'; import { Alert } from 'react-native'; -import { threadIsPending } from 'lib/shared/thread-utils'; +import { clearThreadsActionType } from '../navigation/action-types'; +import { useActiveThread } from '../navigation/nav-selectors'; import { NavContext } from '../navigation/navigation-context'; import { getStateFromNavigatorRoute, getThreadIDFromRoute, } from '../navigation/navigation-utils'; -import { useActiveThread } from '../navigation/nav-selectors'; -import { clearThreadsActionType } from '../navigation/action-types'; +import type { AppState } from '../redux/redux-setup'; import { useSelector } from '../redux/redux-utils'; const ThreadScreenPruner = React.memo<{||}>(() => { const rawThreadInfos = useSelector( (state: AppState) => state.threadStore.threadInfos, ); const navContext = React.useContext(NavContext); const chatRoute = React.useMemo(() => { if (!navContext) { return null; } const { state } = navContext; const appState = getStateFromNavigatorRoute(state.routes[0]); const tabState = getStateFromNavigatorRoute(appState.routes[0]); return getStateFromNavigatorRoute(tabState.routes[1]); }, [navContext]); const inStackThreadIDs = React.useMemo(() => { const threadIDs = new Set(); if (!chatRoute) { return threadIDs; } for (let route of chatRoute.routes) { const threadID = getThreadIDFromRoute(route); if (threadID && !threadIsPending(threadID)) { threadIDs.add(threadID); } } return threadIDs; }, [chatRoute]); const pruneThreadIDs = React.useMemo(() => { const threadIDs = []; for (let threadID of inStackThreadIDs) { if (!rawThreadInfos[threadID]) { threadIDs.push(threadID); } } return threadIDs; }, [inStackThreadIDs, rawThreadInfos]); const activeThreadID = useActiveThread(); React.useEffect(() => { if (pruneThreadIDs.length === 0 || !navContext) { return; } if (activeThreadID && pruneThreadIDs.includes(activeThreadID)) { Alert.alert( 'Thread invalidated', 'You no longer have permission to view this thread :(', [{ text: 'OK' }], { cancelable: true }, ); } navContext.dispatch({ type: clearThreadsActionType, payload: { threadIDs: pruneThreadIDs }, }); }, [pruneThreadIDs, navContext, activeThreadID]); return null; }); ThreadScreenPruner.displayName = 'ThreadScreenPruner'; export default ThreadScreenPruner; diff --git a/native/chat/thread-settings-button.react.js b/native/chat/thread-settings-button.react.js index ebea90862..5411bdc81 100644 --- a/native/chat/thread-settings-button.react.js +++ b/native/chat/thread-settings-button.react.js @@ -1,58 +1,57 @@ // @flow import { type ThreadInfo, threadInfoPropType } from 'lib/types/thread-types'; -import type { AppState } from '../redux/redux-setup'; -import type { ChatNavigationProp } from './chat.react'; - +import { connect } from 'lib/utils/redux-utils'; +import PropTypes from 'prop-types'; import * as React from 'react'; import Icon from 'react-native-vector-icons/Ionicons'; -import PropTypes from 'prop-types'; -import { connect } from 'lib/utils/redux-utils'; - -import { ThreadSettingsRouteName } from '../navigation/route-names'; import Button from '../components/button.react'; +import { ThreadSettingsRouteName } from '../navigation/route-names'; +import type { AppState } from '../redux/redux-setup'; import { styleSelector } from '../themes/colors'; +import type { ChatNavigationProp } from './chat.react'; + type Props = {| threadInfo: ThreadInfo, navigate: $PropertyType, 'navigate'>, // Redux state styles: typeof styles, |}; class ThreadSettingsButton extends React.PureComponent { static propTypes = { threadInfo: threadInfoPropType.isRequired, navigate: PropTypes.func.isRequired, styles: PropTypes.objectOf(PropTypes.object).isRequired, }; render() { return ( ); } onPress = () => { const threadInfo = this.props.threadInfo; this.props.navigate({ name: ThreadSettingsRouteName, params: { threadInfo }, key: `${ThreadSettingsRouteName}${threadInfo.id}`, }); }; } const styles = { button: { color: 'link', paddingHorizontal: 10, }, }; const stylesSelector = styleSelector(styles); export default connect((state: AppState) => ({ styles: stylesSelector(state), }))(ThreadSettingsButton); diff --git a/native/chat/timestamp.react.js b/native/chat/timestamp.react.js index 619e1caa7..076d90287 100644 --- a/native/chat/timestamp.react.js +++ b/native/chat/timestamp.react.js @@ -1,55 +1,53 @@ // @flow -import type { AppState } from '../redux/redux-setup'; - -import * as React from 'react'; - import { longAbsoluteDate } from 'lib/utils/date-utils'; import { connect } from 'lib/utils/redux-utils'; +import * as React from 'react'; -import { styleSelector } from '../themes/colors'; import { SingleLine } from '../components/single-line.react'; +import type { AppState } from '../redux/redux-setup'; +import { styleSelector } from '../themes/colors'; export type DisplayType = 'lowContrast' | 'modal'; type Props = {| time: number, display: DisplayType, // Redux state styles: typeof styles, |}; function Timestamp(props: Props) { const style = [props.styles.timestamp]; if (props.display === 'modal') { style.push(props.styles.modal); } return ( {longAbsoluteDate(props.time).toUpperCase()} ); } const timestampHeight = 26; const styles = { modal: { // high contrast framed against OverlayNavigator-dimmed background color: 'white', }, timestamp: { alignSelf: 'center', bottom: 0, color: 'listBackgroundTernaryLabel', fontSize: 14, height: timestampHeight, paddingVertical: 3, }, }; const stylesSelector = styleSelector(styles); const WrappedTimestamp = connect((state: AppState) => ({ styles: stylesSelector(state), }))(Timestamp); export { WrappedTimestamp as Timestamp, timestampHeight }; diff --git a/native/components/button.react.js b/native/components/button.react.js index 2dfd35d36..502ae38c1 100644 --- a/native/components/button.react.js +++ b/native/components/button.react.js @@ -1,118 +1,118 @@ // @flow -import type { ViewStyle } from '../types/styles'; - +import invariant from 'invariant'; +import PropTypes from 'prop-types'; import * as React from 'react'; import { Platform, View, TouchableNativeFeedback, TouchableHighlight, ViewPropTypes, TouchableOpacity, } from 'react-native'; -import PropTypes from 'prop-types'; -import invariant from 'invariant'; + +import type { ViewStyle } from '../types/styles'; const ANDROID_VERSION_LOLLIPOP = 21; type Props = { onPress: () => *, disabled?: boolean, style?: ViewStyle, // style and topStyle just get merged in most cases. The separation only // matters in the case of iOS and iosFormat = "highlight", where the // topStyle is necessary for layout, and the bottom style is necessary for // colors etc. topStyle?: ViewStyle, children?: React.Node, androidBorderlessRipple: boolean, iosFormat: 'highlight' | 'opacity', androidFormat: 'ripple' | 'highlight' | 'opacity', iosHighlightUnderlayColor?: string, iosActiveOpacity: number, }; class Button extends React.PureComponent { static propTypes = { onPress: PropTypes.func.isRequired, disabled: PropTypes.bool, style: ViewPropTypes.style, topStyle: ViewPropTypes.style, children: PropTypes.node, androidBorderlessRipple: PropTypes.bool, iosFormat: PropTypes.oneOf(['highlight', 'opacity']), androidFormat: PropTypes.oneOf(['ripple', 'highlight', 'opacity']), iosHighlightUnderlayColor: PropTypes.string, iosActiveOpacity: PropTypes.number, }; static defaultProps = { androidBorderlessRipple: false, iosFormat: 'opacity', androidFormat: 'ripple', iosActiveOpacity: 0.2, }; render() { if ( Platform.OS === 'android' && this.props.androidFormat === 'ripple' && Platform.Version >= ANDROID_VERSION_LOLLIPOP ) { return ( {this.props.children} ); } let format = 'opacity'; if (Platform.OS === 'ios') { format = this.props.iosFormat; } else if ( Platform.OS === 'android' && this.props.androidFormat !== 'ripple' ) { format = this.props.androidFormat; } if (format === 'highlight') { const underlayColor = this.props.iosHighlightUnderlayColor; invariant( underlayColor, 'iosHighlightUnderlayColor should be specified to Button in ' + "format='highlight'", ); return ( {this.props.children} ); } else { return ( {this.props.children} ); } } } export default Button; diff --git a/native/components/clearable-text-input.react.ios.js b/native/components/clearable-text-input.react.ios.js index 0dbbf2580..5eba4fc7d 100644 --- a/native/components/clearable-text-input.react.ios.js +++ b/native/components/clearable-text-input.react.ios.js @@ -1,187 +1,187 @@ // @flow -import type { ClearableTextInputProps } from './clearable-text-input'; -import type { KeyPressEvent } from '../types/react-native'; - +import invariant from 'invariant'; import * as React from 'react'; import { TextInput, View, StyleSheet } from 'react-native'; -import invariant from 'invariant'; +import type { KeyPressEvent } from '../types/react-native'; + +import type { ClearableTextInputProps } from './clearable-text-input'; type State = {| textInputKey: number, |}; class ClearableTextInput extends React.PureComponent< ClearableTextInputProps, State, > { state: State = { textInputKey: 0, }; pendingMessage: ?{| value: string, resolve: (value: string) => void |}; lastKeyPressed: ?string; lastTextInputSent = -1; currentTextInput: ?React.ElementRef; focused = false; sendMessage() { if (this.pendingMessageSent) { return; } const { pendingMessage } = this; invariant(pendingMessage, 'cannot send an empty message'); pendingMessage.resolve(pendingMessage.value); const textInputSent = this.state.textInputKey - 1; if (textInputSent > this.lastTextInputSent) { this.lastTextInputSent = textInputSent; } } get pendingMessageSent() { return this.lastTextInputSent >= this.state.textInputKey - 1; } onOldInputChangeText = (text: string) => { const { pendingMessage, lastKeyPressed } = this; invariant( pendingMessage, 'onOldInputChangeText should have a pendingMessage', ); if ( !this.pendingMessageSent && lastKeyPressed && lastKeyPressed.length > 1 ) { // This represents an autocorrect event on blur pendingMessage.value = text; } this.lastKeyPressed = null; this.sendMessage(); this.updateTextFromOldInput(text); }; updateTextFromOldInput(text: string) { const { pendingMessage } = this; invariant( pendingMessage, 'updateTextFromOldInput should have a pendingMessage', ); const pendingValue = pendingMessage.value; if (!pendingValue || !text.startsWith(pendingValue)) { return; } const newValue = text.substring(pendingValue.length); if (this.props.value === newValue) { return; } this.props.onChangeText(newValue); } onOldInputKeyPress = (event: KeyPressEvent) => { const { key } = event.nativeEvent; if (this.lastKeyPressed && this.lastKeyPressed.length > key.length) { return; } this.lastKeyPressed = key; this.props.onKeyPress && this.props.onKeyPress(event); }; onOldInputBlur = () => { this.sendMessage(); }; onOldInputFocus = () => { // It's possible for the user to press the old input after the new one // appears. We can prevent that with pointerEvents="none", but that causes a // blur event when we set it, which makes the keyboard briefly pop down // before popping back up again when textInputRef is called below. Instead // we try to catch the focus event here and refocus the currentTextInput if (this.currentTextInput) { this.currentTextInput.focus(); } }; textInputRef = (textInput: ?React.ElementRef) => { if (this.focused && textInput) { textInput.focus(); } this.currentTextInput = textInput; this.props.textInputRef(textInput); }; async getValueAndReset(): Promise { const { value } = this.props; this.props.onChangeText(''); if (!this.focused) { return value; } return await new Promise((resolve) => { this.pendingMessage = { value, resolve }; this.setState((prevState) => ({ textInputKey: prevState.textInputKey + 1, })); }); } onFocus = () => { this.focused = true; }; onBlur = () => { this.focused = false; if (this.pendingMessage) { // This is to catch a race condition where somebody hits the send button // and then blurs the TextInput before the textInputKey increment can // rerender this component. With this.focused set to false, the new // TextInput won't focus, and the old TextInput won't blur, which means // nothing will call sendMessage unless we do it right here. this.sendMessage(); } }; render() { const { textInputRef, ...props } = this.props; const textInputs = []; if (this.state.textInputKey > 0) { textInputs.push( , ); } textInputs.push( , ); return {textInputs}; } } const styles = StyleSheet.create({ invisibleTextInput: { opacity: 0, position: 'absolute', }, textInputContainer: { flex: 1, }, }); export default ClearableTextInput; diff --git a/native/components/clearable-text-input.react.js b/native/components/clearable-text-input.react.js index a8bebb51b..79f21e18a 100644 --- a/native/components/clearable-text-input.react.js +++ b/native/components/clearable-text-input.react.js @@ -1,77 +1,76 @@ // @flow -import type { ClearableTextInputProps } from './clearable-text-input'; - +import sleep from 'lib/utils/sleep'; import * as React from 'react'; import { TextInput, View, StyleSheet } from 'react-native'; -import sleep from 'lib/utils/sleep'; - import { waitForInteractions } from '../utils/timers'; +import type { ClearableTextInputProps } from './clearable-text-input'; + class ClearableTextInput extends React.PureComponent { textInput: ?React.ElementRef; lastMessageSent: ?string; queuedResolve: ?() => mixed; onChangeText = (inputText: string) => { let text; if (this.lastMessageSent && inputText.startsWith(this.lastMessageSent)) { text = inputText.substring(this.lastMessageSent.length); } else { text = inputText; this.lastMessageSent = null; } this.props.onChangeText(text); }; getValueAndReset(): Promise { const { value } = this.props; this.lastMessageSent = value; this.props.onChangeText(''); if (this.textInput) { this.textInput.clear(); } return new Promise((resolve) => { this.queuedResolve = async () => { await waitForInteractions(); await sleep(5); resolve(value); }; }); } componentDidUpdate(prevProps: ClearableTextInputProps) { if (!this.props.value && prevProps.value && this.queuedResolve) { const resolve = this.queuedResolve; this.queuedResolve = null; resolve(); } } render() { const { textInputRef, ...props } = this.props; return ( ); } textInputRef = (textInput: ?React.ElementRef) => { this.textInput = textInput; this.props.textInputRef(textInput); }; } const styles = StyleSheet.create({ textInputContainer: { flex: 1, }, }); export default ClearableTextInput; diff --git a/native/components/color-picker.react.js b/native/components/color-picker.react.js index 76b8b6591..9361e1dad 100644 --- a/native/components/color-picker.react.js +++ b/native/components/color-picker.react.js @@ -1,663 +1,661 @@ // @flow -import type { AppState } from '../redux/redux-setup'; -import type { LayoutEvent } from '../types/react-native'; - -import type { ViewStyle } from '../types/styles'; - -import * as React from 'react'; +import invariant from 'invariant'; +import { connect } from 'lib/utils/redux-utils'; import PropTypes from 'prop-types'; +import * as React from 'react'; import { View, Image, StyleSheet, I18nManager, PanResponder, ViewPropTypes, Text, Keyboard, } from 'react-native'; import tinycolor from 'tinycolor2'; -import invariant from 'invariant'; -import { connect } from 'lib/utils/redux-utils'; +import type { AppState } from '../redux/redux-setup'; +import { type Colors, colorsPropType, colorsSelector } from '../themes/colors'; +import type { LayoutEvent } from '../types/react-native'; +import type { ViewStyle } from '../types/styles'; import Button from './button.react'; -import { type Colors, colorsPropType, colorsSelector } from '../themes/colors'; type PanEvent = $ReadOnly<{ nativeEvent: $ReadOnly<{ pageX: number, pageY: number, }>, }>; type HSVColor = {| h: number, s: number, v: number |}; type PickerContainer = React.ElementRef; type Props = {| color?: string | HSVColor, defaultColor?: string, oldColor?: ?string, onColorChange?: (color: HSVColor) => void, onColorSelected?: (color: string) => void, onOldColorSelected?: (color: string) => void, style?: ViewStyle, buttonText: string, oldButtonText: string, // Redux state colors: Colors, |}; type State = {| color: HSVColor, pickerSize: ?number, |}; class ColorPicker extends React.PureComponent { static propTypes = { color: PropTypes.oneOfType([ PropTypes.string, PropTypes.shape({ h: PropTypes.number, s: PropTypes.number, v: PropTypes.number, }), ]), defaultColor: PropTypes.string, oldColor: PropTypes.string, onColorChange: PropTypes.func, onColorSelected: PropTypes.func, onOldColorSelected: PropTypes.func, style: ViewPropTypes.style, buttonText: PropTypes.string, oldButtonText: PropTypes.string, colors: colorsPropType.isRequired, }; static defaultProps = { buttonText: 'Select', oldButtonText: 'Reset', }; _layout = { width: 0, height: 0 }; _pageX = 0; _pageY = 0; _pickerContainer: ?PickerContainer = null; _pickerResponder = null; _changingHColor = false; constructor(props: Props) { super(props); let color; if (props.defaultColor) { color = tinycolor(props.defaultColor).toHsv(); } else if (props.oldColor) { color = tinycolor(props.oldColor).toHsv(); } else { color = { h: 0, s: 1, v: 1 }; } this.state = { color, pickerSize: null }; const handleColorChange = ({ x, y }: { x: number, y: number }) => { if (this._changingHColor) { this._handleHColorChange({ x, y }); } else { this._handleSVColorChange({ x, y }); } }; this._pickerResponder = PanResponder.create({ onStartShouldSetPanResponder: () => true, onStartShouldSetPanResponderCapture: () => true, onMoveShouldSetPanResponder: () => true, onMoveShouldSetPanResponderCapture: () => true, onPanResponderTerminationRequest: () => true, onPanResponderGrant: (evt: PanEvent) => { const x = evt.nativeEvent.pageX; const y = evt.nativeEvent.pageY; const { s, v } = this._computeColorFromTriangle({ x, y }); this._changingHColor = s > 1 || s < 0 || v > 1 || v < 0; handleColorChange({ x, y }); }, onPanResponderMove: (evt: PanEvent) => handleColorChange({ x: evt.nativeEvent.pageX, y: evt.nativeEvent.pageY, }), onPanResponderRelease: () => true, }); } componentDidMount() { Keyboard.dismiss(); } _getColor(): HSVColor { const passedColor = typeof this.props.color === 'string' ? tinycolor(this.props.color).toHsv() : this.props.color; return passedColor || this.state.color; } _onColorSelected = () => { const { onColorSelected } = this.props; const color = tinycolor(this._getColor()).toHexString(); onColorSelected && onColorSelected(color); }; _onOldColorSelected = () => { const { oldColor, onOldColorSelected } = this.props; const color = tinycolor(oldColor); this.setState({ color: color.toHsv() }); onOldColorSelected && onOldColorSelected(color.toHexString()); }; _onSValueChange = (s: number) => { const { h, v } = this._getColor(); this._onColorChange({ h, s, v }); }; _onVValueChange = (v: number) => { const { h, s } = this._getColor(); this._onColorChange({ h, s, v }); }; _onColorChange(color: HSVColor) { this.setState({ color }); if (this.props.onColorChange) { this.props.onColorChange(color); } } _onLayout = (l: LayoutEvent) => { this._layout = l.nativeEvent.layout; const { width, height } = this._layout; const pickerSize = Math.round(Math.min(width, height)); if ( !this.state.pickerSize || Math.abs(this.state.pickerSize - pickerSize) >= 3 ) { this.setState({ pickerSize }); } // We need to get pageX/pageY, ie. the absolute position of the picker on // the screen. This is because PanResponder's relative position information // is double broken (#12591, #15290). Unfortunately, the only way to get // absolute positioning for a View is via measure() after onLayout (#10556). // The setTimeout is necessary to make sure that the ColorPickerModal // completes its slide-in animation before we measure. setTimeout(() => { if (!this._pickerContainer) { return; } this._pickerContainer.measure((x, y, cWidth, cHeight, pageX, pageY) => { const { pickerPadding } = getPickerProperties(pickerSize); this._pageX = pageX; this._pageY = pageY - pickerPadding - 3; }); }, 500); }; _computeHValue(x: number, y: number) { const pickerSize = this.state.pickerSize; invariant( pickerSize !== null && pickerSize !== undefined, 'pickerSize should be set', ); const dx = x - pickerSize / 2; const dy = y - pickerSize / 2; const rad = Math.atan2(dx, dy) + Math.PI + Math.PI / 2; return ((rad * 180) / Math.PI) % 360; } _hValueToRad(deg: number) { const rad = (deg * Math.PI) / 180; return rad - Math.PI - Math.PI / 2; } getColor(): string { return tinycolor(this._getColor()).toHexString(); } _handleHColorChange({ x, y }: { x: number, y: number }) { const { s, v } = this._getColor(); const { pickerSize } = this.state; invariant( pickerSize !== null && pickerSize !== undefined, 'pickerSize should be set', ); const marginLeft = (this._layout.width - pickerSize) / 2; const marginTop = (this._layout.height - pickerSize) / 2; const relativeX = x - this._pageX - marginLeft; const relativeY = y - this._pageY - marginTop; const h = this._computeHValue(relativeX, relativeY); this._onColorChange({ h, s, v }); } _handleSVColorChange({ x, y }: { x: number, y: number }) { const { h, s: rawS, v: rawV } = this._computeColorFromTriangle({ x, y }); const s = Math.min(Math.max(0, rawS), 1); const v = Math.min(Math.max(0, rawV), 1); this._onColorChange({ h, s, v }); } _normalizeTriangleTouch( s: number, v: number, sRatio: number, ): { s: number, v: number } { // relative size to be considered as corner zone const CORNER_ZONE_SIZE = 0.12; // relative triangle margin to be considered as touch in triangle const NORMAL_MARGIN = 0.1; // relative triangle margin to be considered as touch in triangle // in corner zone const CORNER_MARGIN = 0.05; let margin = NORMAL_MARGIN; const posNS = v > 0 ? 1 - (1 - s) * sRatio : 1 - s * sRatio; const negNS = v > 0 ? s * sRatio : (1 - s) * sRatio; // normalized s value according to ratio and s value const ns = s > 1 ? posNS : negNS; const rightCorner = s > 1 - CORNER_ZONE_SIZE && v > 1 - CORNER_ZONE_SIZE; const leftCorner = ns < 0 + CORNER_ZONE_SIZE && v > 1 - CORNER_ZONE_SIZE; const topCorner = ns < 0 + CORNER_ZONE_SIZE && v < 0 + CORNER_ZONE_SIZE; if (rightCorner) { return { s, v }; } if (leftCorner || topCorner) { margin = CORNER_MARGIN; } // color normalization according to margin s = s < 0 && ns > 0 - margin ? 0 : s; s = s > 1 && ns < 1 + margin ? 1 : s; v = v < 0 && v > 0 - margin ? 0 : v; v = v > 1 && v < 1 + margin ? 1 : v; return { s, v }; } /** * Computes s, v from position (x, y). If position is outside of triangle, * it will return invalid values (greater than 1 or lower than 0) */ _computeColorFromTriangle({ x, y }: { x: number, y: number }): HSVColor { const { pickerSize } = this.state; invariant( pickerSize !== null && pickerSize !== undefined, 'pickerSize should be set', ); const { triangleHeight, triangleWidth } = getPickerProperties(pickerSize); const left = pickerSize / 2 - triangleWidth / 2; const top = pickerSize / 2 - (2 * triangleHeight) / 3; // triangle relative coordinates const marginLeft = (this._layout.width - pickerSize) / 2; const marginTop = (this._layout.height - pickerSize) / 2; const relativeX = x - this._pageX - marginLeft - left; const relativeY = y - this._pageY - marginTop - top; // rotation const { h } = this._getColor(); // starting angle is 330 due to comfortable calculation const deg = (h - 330 + 360) % 360; const rad = (deg * Math.PI) / 180; const center = { x: triangleWidth / 2, y: (2 * triangleHeight) / 3, }; const rotated = rotatePoint({ x: relativeX, y: relativeY }, rad, center); const line = (triangleWidth * rotated.y) / triangleHeight; const margin = triangleWidth / 2 - ((triangleWidth / 2) * rotated.y) / triangleHeight; const s = (rotated.x - margin) / line; const v = rotated.y / triangleHeight; // normalize const normalized = this._normalizeTriangleTouch( s, v, line / triangleHeight, ); return { h, s: normalized.s, v: normalized.v }; } render() { const { pickerSize } = this.state; const { style } = this.props; const color = this._getColor(); const tc = tinycolor(color); const selectedColor: string = tc.toHexString(); const isDark: boolean = tc.isDark(); const { modalIosHighlightUnderlay: underlayColor } = this.props.colors; let picker = null; if (pickerSize) { const pickerResponder = this._pickerResponder; invariant(pickerResponder, 'should be set'); const { h } = color; const indicatorColor = tinycolor({ h, s: 1, v: 1 }).toHexString(); const angle = this._hValueToRad(h); const computed = makeComputedStyles({ pickerSize, selectedColor, selectedColorHsv: color, indicatorColor, oldColor: this.props.oldColor, angle, isRTL: I18nManager.isRTL, }); picker = ( ); } let oldColorButton = null; if (this.props.oldColor) { const oldTinyColor = tinycolor(this.props.oldColor); const oldButtonTextStyle = { color: oldTinyColor.isDark() ? 'white' : 'black', }; oldColorButton = ( ); } const colorPreviewsStyle = { height: this.state.pickerSize ? this.state.pickerSize * 0.1 // responsive height : 20, }; const buttonContentsStyle = { backgroundColor: selectedColor, }; const buttonTextStyle = { color: isDark ? 'white' : 'black', }; return ( {picker} {oldColorButton} ); } pickerContainerRef = (pickerContainer: ?PickerContainer) => { this._pickerContainer = pickerContainer; }; } function getPickerProperties(pickerSize) { const indicatorPickerRatio = 42 / 510; // computed from picker image const originalIndicatorSize = indicatorPickerRatio * pickerSize; const indicatorSize = originalIndicatorSize; const pickerPadding = originalIndicatorSize / 3; const triangleSize = pickerSize - 6 * pickerPadding; const triangleRadius = triangleSize / 2; const triangleHeight = (triangleRadius * 3) / 2; // pythagorean theorem const triangleWidth = 2 * triangleRadius * Math.sqrt(3 / 4); return { triangleSize, triangleRadius, triangleHeight, triangleWidth, indicatorPickerRatio, indicatorSize, pickerPadding, }; } const makeComputedStyles = ({ indicatorColor, angle, pickerSize, selectedColorHsv, isRTL, }) => { const { triangleSize, triangleHeight, triangleWidth, indicatorSize, pickerPadding, } = getPickerProperties(pickerSize); /* ===== INDICATOR ===== */ const indicatorRadius = pickerSize / 2 - indicatorSize / 2 - pickerPadding; const mx = pickerSize / 2; const my = pickerSize / 2; const dx = Math.cos(angle) * indicatorRadius; const dy = Math.sin(angle) * indicatorRadius; /* ===== TRIANGLE ===== */ const triangleTop = pickerPadding * 3; const triangleLeft = pickerPadding * 3; const triangleAngle = -angle + Math.PI / 3; /* ===== SV INDICATOR ===== */ const markerColor = 'rgba(0,0,0,0.8)'; const { s, v, h } = selectedColorHsv; const svIndicatorSize = 18; const svY = v * triangleHeight; const margin = triangleWidth / 2 - v * (triangleWidth / 2); const svX = s * (triangleWidth - 2 * margin) + margin; const svIndicatorMarginLeft = (pickerSize - triangleWidth) / 2; const svIndicatorMarginTop = (pickerSize - (4 * triangleHeight) / 3) / 2; // starting angle is 330 due to comfortable calculation const deg = (h - 330 + 360) % 360; const rad = (deg * Math.PI) / 180; const center = { x: pickerSize / 2, y: pickerSize / 2 }; const notRotatedPoint = { x: svIndicatorMarginTop + svY, y: svIndicatorMarginLeft + svX, }; const svIndicatorPoint = rotatePoint(notRotatedPoint, rad, center); const offsetDirection: string = isRTL ? 'right' : 'left'; return { picker: { padding: pickerPadding, width: pickerSize, height: pickerSize, }, pickerIndicator: { top: mx + dx - indicatorSize / 2, [offsetDirection]: my + dy - indicatorSize / 2, width: indicatorSize, height: indicatorSize, transform: [ { rotate: -angle + 'rad', }, ], }, pickerIndicatorTick: { height: indicatorSize / 2, backgroundColor: markerColor, }, svIndicator: { top: svIndicatorPoint.x - svIndicatorSize / 2, [offsetDirection]: svIndicatorPoint.y - svIndicatorSize / 2, width: svIndicatorSize, height: svIndicatorSize, borderRadius: svIndicatorSize / 2, borderColor: markerColor, }, triangleContainer: { width: triangleSize, height: triangleSize, transform: [ { rotate: triangleAngle + 'rad', }, ], top: triangleTop, left: triangleLeft, }, triangleImage: { width: triangleWidth, height: triangleHeight, }, triangleUnderlayingColor: { left: (triangleSize - triangleWidth) / 2, borderLeftWidth: triangleWidth / 2, borderRightWidth: triangleWidth / 2, borderBottomWidth: triangleHeight, borderBottomColor: indicatorColor, }, }; }; type Point = { x: number, y: number }; function rotatePoint( point: Point, angle: number, center: Point = { x: 0, y: 0 }, ) { // translation to origin const transOriginX = point.x - center.x; const transOriginY = point.y - center.y; // rotation around origin const rotatedX = transOriginX * Math.cos(angle) - transOriginY * Math.sin(angle); const rotatedY = transOriginY * Math.cos(angle) + transOriginX * Math.sin(angle); // translate back from origin const normalizedX = rotatedX + center.x; const normalizedY = rotatedY + center.y; return { x: normalizedX, y: normalizedY, }; } const styles = StyleSheet.create({ buttonContents: { borderRadius: 3, flex: 1, padding: 3, }, buttonText: { flex: 1, fontSize: 20, textAlign: 'center', }, colorPreview: { flex: 1, marginHorizontal: 5, }, colorPreviews: { flexDirection: 'row', }, pickerContainer: { alignItems: 'center', flex: 1, justifyContent: 'center', }, pickerImage: { flex: 1, height: null, width: null, }, pickerIndicator: { alignItems: 'center', justifyContent: 'center', position: 'absolute', }, pickerIndicatorTick: { width: 5, }, svIndicator: { borderWidth: 4, position: 'absolute', }, triangleContainer: { alignItems: 'center', position: 'absolute', }, triangleUnderlayingColor: { backgroundColor: 'transparent', borderLeftColor: 'transparent', borderRightColor: 'transparent', borderStyle: 'solid', height: 0, position: 'absolute', top: 0, width: 0, }, }); export default connect((state: AppState) => ({ colors: colorsSelector(state), }))(ColorPicker); diff --git a/native/components/content-loading.react.js b/native/components/content-loading.react.js index 26845f792..7b5376133 100644 --- a/native/components/content-loading.react.js +++ b/native/components/content-loading.react.js @@ -1,39 +1,39 @@ // @flow -import type { Colors } from '../themes/colors'; - import * as React from 'react'; import { View, ActivityIndicator, StyleSheet } from 'react-native'; +import type { Colors } from '../themes/colors'; + type Props = {| fillType: 'flex' | 'absolute', colors: Colors, |}; function ContentLoading(props: Props) { const viewStyle = props.fillType === 'flex' ? styles.fullFlex : styles.absoluteContainer; return ( ); } const styles = StyleSheet.create({ absoluteContainer: { bottom: 0, left: 0, position: 'absolute', right: 0, top: 0, }, fullFlex: { flex: 1, }, }); export default ContentLoading; diff --git a/native/components/edit-setting-button.react.js b/native/components/edit-setting-button.react.js index bd9cc055b..9d752659c 100644 --- a/native/components/edit-setting-button.react.js +++ b/native/components/edit-setting-button.react.js @@ -1,48 +1,46 @@ // @flow -import type { TextStyle } from '../types/styles'; -import type { AppState } from '../redux/redux-setup'; -import type { Colors } from '../themes/colors'; - +import { connect } from 'lib/utils/redux-utils'; import * as React from 'react'; import { TouchableOpacity, StyleSheet, Platform } from 'react-native'; import Icon from 'react-native-vector-icons/FontAwesome'; -import { connect } from 'lib/utils/redux-utils'; - +import type { AppState } from '../redux/redux-setup'; +import type { Colors } from '../themes/colors'; import { colorsSelector } from '../themes/colors'; +import type { TextStyle } from '../types/styles'; type Props = {| onPress: () => void, canChangeSettings: boolean, style?: TextStyle, // Redux state colors: Colors, |}; function EditSettingButton(props: Props) { if (!props.canChangeSettings) { return null; } const appliedStyles = [styles.editIcon]; if (props.style) { appliedStyles.push(props.style); } const { link: linkColor } = props.colors; return ( ); } const styles = StyleSheet.create({ editIcon: { paddingLeft: 10, paddingTop: Platform.select({ android: 1, default: 0 }), textAlign: 'right', }, }); export default connect((state: AppState) => ({ colors: colorsSelector(state), }))(EditSettingButton); diff --git a/native/components/gesture-touchable-opacity.react.js b/native/components/gesture-touchable-opacity.react.js index afa5dab78..342ef47e9 100644 --- a/native/components/gesture-touchable-opacity.react.js +++ b/native/components/gesture-touchable-opacity.react.js @@ -1,200 +1,199 @@ // @flow -import type { ViewStyle } from '../types/styles'; - import * as React from 'react'; import { StyleSheet } from 'react-native'; import { LongPressGestureHandler, TapGestureHandler, State as GestureState, } from 'react-native-gesture-handler'; import Animated, { Easing } from 'react-native-reanimated'; +import type { ViewStyle } from '../types/styles'; import { runTiming, useReanimatedValueForBoolean, } from '../utils/animation-utils'; /* eslint-disable import/no-named-as-default-member */ const { Value, Clock, block, event, set, call, cond, not, and, or, eq, stopClock, clockRunning, } = Animated; /* eslint-enable import/no-named-as-default-member */ const pressAnimationSpec = { duration: 150, easing: Easing.inOut(Easing.quad), }; const resetAnimationSpec = { duration: 250, easing: Easing.inOut(Easing.quad), }; type Props = {| +activeOpacity?: number, +onPress?: () => mixed, +onLongPress?: () => mixed, +children?: React.Node, +style?: ViewStyle, // If stickyActive is a boolean, we assume that we should stay active after a // successful onPress or onLongPress. We will wait for stickyActive to flip // from true to false before animating back to our deactivated mode. +stickyActive?: boolean, +overlay?: React.Node, +disabled?: boolean, |}; function GestureTouchableOpacity(props: Props) { const { onPress: innerOnPress, onLongPress: innerOnLongPress } = props; const onPress = React.useCallback(() => { innerOnPress && innerOnPress(); }, [innerOnPress]); const onLongPress = React.useCallback(() => { innerOnLongPress && innerOnLongPress(); }, [innerOnLongPress]); const activeOpacity = props.activeOpacity ?? 0.2; const { stickyActive, disabled } = props; const activeValue = useReanimatedValueForBoolean(!!stickyActive); const disabledValue = useReanimatedValueForBoolean(!!disabled); const stickyActiveEnabled = stickyActive !== null && stickyActive !== undefined; const { longPressEvent, tapEvent, transformStyle } = React.useMemo(() => { const longPressState = new Value(-1); const innerLongPressEvent = event([ { nativeEvent: { state: longPressState, }, }, ]); const tapState = new Value(-1); const innerTapEvent = event([ { nativeEvent: { state: tapState, }, }, ]); const gestureActive = or( eq(longPressState, GestureState.ACTIVE), eq(tapState, GestureState.BEGAN), eq(tapState, GestureState.ACTIVE), activeValue, ); const tapSuccess = eq(tapState, GestureState.END); const prevTapSuccess = new Value(0); const longPressSuccess = eq(longPressState, GestureState.ACTIVE); const prevLongPressSuccess = new Value(0); const curOpacity = new Value(1); const pressClock = new Clock(); const resetClock = new Clock(); const opacity = block([ cond(or(gestureActive, clockRunning(pressClock)), [ set( curOpacity, runTiming( pressClock, curOpacity, activeOpacity, true, pressAnimationSpec, ), ), stopClock(resetClock), ]), // We have to do two separate conds here even though the condition is the // same because if runTiming stops the pressClock, we need to immediately // start the resetClock or Reanimated won't keep running the code because // it will think there is nothing left to do cond( not(or(gestureActive, clockRunning(pressClock))), set( curOpacity, runTiming(resetClock, curOpacity, 1, true, resetAnimationSpec), ), ), [ cond(and(tapSuccess, not(prevTapSuccess), not(disabledValue)), [ stickyActiveEnabled ? set(activeValue, 1) : undefined, call([], onPress), ]), set(prevTapSuccess, tapSuccess), ], [ cond( and(longPressSuccess, not(prevLongPressSuccess), not(disabledValue)), [ stickyActiveEnabled ? set(activeValue, 1) : undefined, call([], onLongPress), ], ), set(prevLongPressSuccess, longPressSuccess), ], curOpacity, ]); const innerTransformStyle = { flex: 1, opacity, }; return { longPressEvent: innerLongPressEvent, tapEvent: innerTapEvent, transformStyle: innerTransformStyle, }; }, [ onPress, onLongPress, activeOpacity, activeValue, disabledValue, stickyActiveEnabled, ]); const tapHandler = ( {props.children} {props.overlay} ); if (!innerOnLongPress) { return tapHandler; } return ( {tapHandler} ); } const styles = StyleSheet.create({ fill: { flex: 1, }, }); export default GestureTouchableOpacity; diff --git a/native/components/keyboard-avoiding-view.react.js b/native/components/keyboard-avoiding-view.react.js index 27bc79558..ef2880553 100644 --- a/native/components/keyboard-avoiding-view.react.js +++ b/native/components/keyboard-avoiding-view.react.js @@ -1,219 +1,218 @@ // @flow -import type { Layout, LayoutEvent } from '../types/react-native'; -import type { ScreenRect, KeyboardEvent } from '../keyboard/keyboard'; -import type { ViewStyle } from '../types/styles'; -import { - type KeyboardState, - KeyboardContext, -} from '../keyboard/keyboard-state'; - +import invariant from 'invariant'; import * as React from 'react'; import { View, Keyboard, Platform, LayoutAnimation, StyleSheet, } from 'react-native'; -import invariant from 'invariant'; +import type { ScreenRect, KeyboardEvent } from '../keyboard/keyboard'; import { androidKeyboardResizesFrame } from '../keyboard/keyboard'; +import { + type KeyboardState, + KeyboardContext, +} from '../keyboard/keyboard-state'; +import type { Layout, LayoutEvent } from '../types/react-native'; +import type { ViewStyle } from '../types/styles'; type ViewProps = React.ElementConfig; type BaseProps = {| ...ViewProps, +behavior: 'height' | 'position' | 'padding', +contentContainerStyle?: ?ViewStyle, |}; export default React.memo(function KeyboardAvoidingView( props: BaseProps, ) { const keyboardState = React.useContext(KeyboardContext); if (!androidKeyboardResizesFrame) { return ( ); } const { behavior, contentContainerStyle, ...viewProps } = props; if (behavior !== 'position') { return ; } const { children, ...restViewProps } = viewProps; return ( {children} ); }); type Props = {| ...BaseProps, // withKeyboardState +keyboardState: ?KeyboardState, |}; type State = {| +bottom: number, |}; type Subscription = { +remove: () => void, ... }; class InnerKeyboardAvoidingView extends React.PureComponent { state: State = { bottom: 0, }; subscriptions: Subscription[] = []; viewFrame: ?Layout; keyboardFrame: ?ScreenRect; defaultViewFrameHeight = 0; waitingForLayout: Array<() => mixed> = []; componentDidMount() { if (Platform.OS === 'ios') { this.subscriptions.push( Keyboard.addListener('keyboardWillChangeFrame', this.onKeyboardChange), ); } else { this.subscriptions.push( Keyboard.addListener('keyboardDidHide', this.onKeyboardChange), Keyboard.addListener('keyboardDidShow', this.onKeyboardChange), ); } } componentWillUnmount() { for (const subscription of this.subscriptions) { subscription.remove(); } } onKeyboardChange = (event: ?KeyboardEvent) => { if (!event) { this.keyboardFrame = null; this.setState({ bottom: 0 }); return; } if (!this.viewFrame) { this.waitingForLayout.push(() => this.onKeyboardChange(event)); return; } const { duration, easing, endCoordinates } = event; if ( Platform.OS === 'android' && this.keyboardFrame?.height && endCoordinates.height ) { // On Android, we don't update the keyboard height when it changes. This // is because the Android keyboard has pop-up menus and the such, and we // want those pop-up menus to simply overlay over our screen instead of // moving it up. return; } this.keyboardFrame = endCoordinates; const { keyboardState } = this.props; const mediaGalleryOpen = keyboardState && keyboardState.mediaGalleryOpen; if ( Platform.OS === 'android' && !androidKeyboardResizesFrame && mediaGalleryOpen && this.keyboardFrame.height > 0 && this.viewFrame ) { this.viewFrame = { ...this.viewFrame, height: this.defaultViewFrameHeight, }; } const height = this.relativeKeyboardHeight; if (height === this.state.bottom) { return; } this.setState({ bottom: height }); if (duration && easing) { LayoutAnimation.configureNext({ duration: duration > 10 ? duration : 10, update: { duration: duration > 10 ? duration : 10, type: LayoutAnimation.Types[easing] || 'keyboard', }, }); } }; get relativeKeyboardHeight() { const { viewFrame, keyboardFrame } = this; if (!viewFrame || !keyboardFrame) { return 0; } return Math.max(viewFrame.y + viewFrame.height - keyboardFrame.screenY, 0); } onLayout = (event: LayoutEvent) => { this.viewFrame = event.nativeEvent.layout; const { keyboardState } = this.props; const keyboardShowing = keyboardState && keyboardState.keyboardShowing; if (!keyboardShowing) { this.defaultViewFrameHeight = this.viewFrame.height; } for (const callback of this.waitingForLayout) { callback(); } this.waitingForLayout = []; }; render() { const { behavior, children, contentContainerStyle, style, keyboardState, ...props } = this.props; const { bottom } = this.state; if (behavior === 'height') { let heightStyle; if (this.viewFrame && bottom > 0) { heightStyle = { height: this.defaultViewFrameHeight - bottom, flex: 0, }; } const composedStyle = StyleSheet.compose(style, heightStyle); return ( {children} ); } else if (behavior === 'position') { const composedStyle = StyleSheet.compose(contentContainerStyle, { bottom, }); const { pointerEvents } = props; return ( {children} ); } else if (behavior === 'padding') { const composedStyle = StyleSheet.compose(style, { paddingBottom: bottom, }); return ( {children} ); } invariant(false, `invalid KeyboardAvoidingView behavior ${behavior}`); } } diff --git a/native/components/link-button.react.js b/native/components/link-button.react.js index 0639637d4..4a23c3c95 100644 --- a/native/components/link-button.react.js +++ b/native/components/link-button.react.js @@ -1,66 +1,65 @@ // @flow -import type { ViewStyle } from '../types/styles'; -import type { AppState } from '../redux/redux-setup'; - +import { connect } from 'lib/utils/redux-utils'; +import PropTypes from 'prop-types'; import * as React from 'react'; import { Text, ViewPropTypes } from 'react-native'; -import PropTypes from 'prop-types'; -import { connect } from 'lib/utils/redux-utils'; +import type { AppState } from '../redux/redux-setup'; +import { styleSelector } from '../themes/colors'; +import type { ViewStyle } from '../types/styles'; import Button from './button.react'; -import { styleSelector } from '../themes/colors'; type Props = { text: string, onPress: () => void, disabled?: boolean, style?: ViewStyle, // Redux state styles: typeof styles, }; class LinkButton extends React.PureComponent { static propTypes = { text: PropTypes.string.isRequired, onPress: PropTypes.func.isRequired, disabled: PropTypes.bool, style: ViewPropTypes.style, styles: PropTypes.objectOf(PropTypes.object).isRequired, }; render() { const disabledStyle = this.props.disabled ? this.props.styles.disabled : null; return ( ); } } const styles = { disabled: { color: 'modalBackgroundSecondaryLabel', }, text: { color: 'link', fontSize: 17, paddingHorizontal: 10, }, }; const stylesSelector = styleSelector(styles); export default connect((state: AppState) => ({ styles: stylesSelector(state), }))(LinkButton); diff --git a/native/components/list-loading-indicator.react.js b/native/components/list-loading-indicator.react.js index d5c0d3e69..518c109da 100644 --- a/native/components/list-loading-indicator.react.js +++ b/native/components/list-loading-indicator.react.js @@ -1,41 +1,39 @@ // @flow -import type { AppState } from '../redux/redux-setup'; -import type { Colors } from '../themes/colors'; - +import { connect } from 'lib/utils/redux-utils'; import * as React from 'react'; import { ActivityIndicator } from 'react-native'; -import { connect } from 'lib/utils/redux-utils'; - +import type { AppState } from '../redux/redux-setup'; +import type { Colors } from '../themes/colors'; import { colorsSelector, styleSelector } from '../themes/colors'; type Props = {| // Redux state colors: Colors, styles: typeof styles, |}; function ListLoadingIndicator(props: Props) { const { listBackgroundLabel } = props.colors; return ( ); } const styles = { loadingIndicator: { backgroundColor: 'listBackground', flex: 1, padding: 10, }, }; const stylesSelector = styleSelector(styles); export default connect((state: AppState) => ({ colors: colorsSelector(state), styles: stylesSelector(state), }))(ListLoadingIndicator); diff --git a/native/components/modal.react.js b/native/components/modal.react.js index 81b73f3fc..2dba8924c 100644 --- a/native/components/modal.react.js +++ b/native/components/modal.react.js @@ -1,86 +1,86 @@ // @flow -import type { AppState } from '../redux/redux-setup'; -import type { ViewStyle } from '../types/styles'; -import type { RootNavigationProp } from '../navigation/root-navigator.react'; - +import { connect } from 'lib/utils/redux-utils'; +import PropTypes from 'prop-types'; import * as React from 'react'; import { View, TouchableWithoutFeedback, ViewPropTypes, StyleSheet, } from 'react-native'; -import PropTypes from 'prop-types'; -import { connect } from 'lib/utils/redux-utils'; import { SafeAreaView } from 'react-native-safe-area-context'; +import type { RootNavigationProp } from '../navigation/root-navigator.react'; +import type { AppState } from '../redux/redux-setup'; import { styleSelector } from '../themes/colors'; +import type { ViewStyle } from '../types/styles'; + import KeyboardAvoidingView from './keyboard-avoiding-view.react'; type Props = $ReadOnly<{| navigation: RootNavigationProp<>, children: React.Node, containerStyle?: ViewStyle, modalStyle?: ViewStyle, // Redux state styles: typeof styles, |}>; class Modal extends React.PureComponent { static propTypes = { children: PropTypes.node, navigation: PropTypes.shape({ isFocused: PropTypes.func.isRequired, goBackOnce: PropTypes.func.isRequired, }).isRequired, styles: PropTypes.objectOf(PropTypes.object).isRequired, containerStyle: ViewPropTypes.style, modalStyle: ViewPropTypes.style, }; close = () => { if (this.props.navigation.isFocused()) { this.props.navigation.goBackOnce(); } }; render() { const { containerStyle, modalStyle, children } = this.props; return ( {children} ); } } const styles = { container: { flex: 1, justifyContent: 'center', overflow: 'visible', }, modal: { backgroundColor: 'modalBackground', borderRadius: 5, flex: 1, justifyContent: 'center', marginBottom: 30, marginHorizontal: 15, marginTop: 100, padding: 12, }, }; const stylesSelector = styleSelector(styles); export default connect((state: AppState) => ({ styles: stylesSelector(state), }))(Modal); diff --git a/native/components/node-height-measurer.react.js b/native/components/node-height-measurer.react.js index 9fe9bc260..52365b6f6 100644 --- a/native/components/node-height-measurer.react.js +++ b/native/components/node-height-measurer.react.js @@ -1,460 +1,460 @@ // @flow -import type { LayoutEvent } from '../types/react-native'; - -import * as React from 'react'; +import invariant from 'invariant'; import PropTypes from 'prop-types'; +import * as React from 'react'; import { View, StyleSheet } from 'react-native'; -import invariant from 'invariant'; import shallowequal from 'shallowequal'; +import type { LayoutEvent } from '../types/react-native'; + const measureBatchSize = 50; type MergedItemPair = {| +item: Item, +mergedItem: MergedItem, |}; type Props = { // What we want to render +listData: ?$ReadOnlyArray, // Every item should have an ID. We use this ID to cache the result of calling // mergeItemWithHeight below, and only update it if the input item changes, // mergeItemWithHeight changes, or any extra props we get passed change +itemToID: (Item) => string, // Only measurable items should return a measureKey. // Falsey keys won't get measured, but will still get passed through // mergeItemWithHeight with height undefined // Make sure that if an item's height changes, its measure key does too! +itemToMeasureKey: (Item) => ?string, // The "dummy" is the component whose height we will be measuring // We will only call this with items for which itemToMeasureKey returns truthy +itemToDummy: (Item) => React.Element, // Once we have the height, we need to merge it into the item +mergeItemWithHeight: (item: Item, height: ?number) => MergedItem, // We'll pass our results here when we're done +allHeightsMeasured: (items: $ReadOnlyArray) => mixed, ... }; type State = {| // These are the dummies currently being rendered +currentlyMeasuring: $ReadOnlyArray<{| +measureKey: string, +dummy: React.Element, |}>, // When certain parameters change we need to remeasure everything. In order to // avoid considering any onLayouts that got queued before we issued the // remeasure, we increment the "iteration" and only count onLayouts with the // right value +iteration: number, // We cache the measured heights here, keyed by measure key +measuredHeights: Map, // We cache the results of calling mergeItemWithHeight on measured items after // measuring their height, keyed by ID +measurableItems: Map>, +measurableItems: Map>, // We cache the results of calling mergeItemWithHeight on items that aren't // measurable (eg. itemToKey reurns falsey), keyed by ID +unmeasurableItems: Map>, |}; class NodeHeightMeasurer extends React.PureComponent< Props, State, > { static propTypes = { listData: PropTypes.arrayOf(PropTypes.object), itemToID: PropTypes.func.isRequired, itemToMeasureKey: PropTypes.func.isRequired, itemToDummy: PropTypes.func.isRequired, mergeItemWithHeight: PropTypes.func.isRequired, allHeightsMeasured: PropTypes.func.isRequired, }; containerWidth: ?number; constructor(props: Props) { super(props); const { listData, itemToID, itemToMeasureKey, mergeItemWithHeight } = props; const unmeasurableItems = new Map(); if (listData) { for (const item of listData) { const measureKey = itemToMeasureKey(item); if (measureKey !== null && measureKey !== undefined) { continue; } const mergedItem = mergeItemWithHeight(item, undefined); unmeasurableItems.set(itemToID(item), { item, mergedItem }); } } this.state = { currentlyMeasuring: [], iteration: 0, measuredHeights: new Map(), measurableItems: new Map(), unmeasurableItems, }; } static getDerivedStateFromProps( props: Props, state: State, ) { return NodeHeightMeasurer.getPossibleStateUpdateForNextBatch< Item, MergedItem, >(props, state); } static getPossibleStateUpdateForNextBatch( props: Props, state: State, ): ?$Shape> { const { currentlyMeasuring, measuredHeights } = state; let stillMeasuring = false; for (const { measureKey } of currentlyMeasuring) { const height = measuredHeights.get(measureKey); if (height === null || height === undefined) { stillMeasuring = true; break; } } if (stillMeasuring) { return null; } const { listData, itemToMeasureKey, itemToDummy } = props; const toMeasure = new Map(); if (listData) { for (const item of listData) { const measureKey = itemToMeasureKey(item); if (measureKey === null || measureKey === undefined) { continue; } const height = measuredHeights.get(measureKey); if (height !== null && height !== undefined) { continue; } const dummy = itemToDummy(item); toMeasure.set(measureKey, dummy); if (toMeasure.size === measureBatchSize) { break; } } } if (currentlyMeasuring.length === 0 && toMeasure.size === 0) { return null; } const nextCurrentlyMeasuring = []; for (const [measureKey, dummy] of toMeasure) { nextCurrentlyMeasuring.push({ measureKey, dummy }); } return { currentlyMeasuring: nextCurrentlyMeasuring, measuredHeights: new Map(measuredHeights), }; } possiblyIssueNewBatch() { const stateUpdate = NodeHeightMeasurer.getPossibleStateUpdateForNextBatch( this.props, this.state, ); if (stateUpdate) { this.setState(stateUpdate); } } componentDidMount() { this.triggerCallback( this.state.measurableItems, this.state.unmeasurableItems, false, ); } triggerCallback( measurableItems: Map>, unmeasurableItems: Map>, mustTrigger: boolean, ) { const { listData, itemToID, itemToMeasureKey, allHeightsMeasured, } = this.props; if (!listData) { return; } const result = []; for (const item of listData) { const id = itemToID(item); const measureKey = itemToMeasureKey(item); if (measureKey !== null && measureKey !== undefined) { const measurableItem = measurableItems.get(id); if (!measurableItem && !mustTrigger) { return; } invariant( measurableItem, `currentlyMeasuring empty but no result for ${id}`, ); result.push(measurableItem.mergedItem); } else { const unmeasurableItem = unmeasurableItems.get(id); if (!unmeasurableItem && !mustTrigger) { return; } invariant( unmeasurableItem, `currentlyMeasuring empty but no result for ${id}`, ); result.push(unmeasurableItem.mergedItem); } } allHeightsMeasured(result); } componentDidUpdate( prevProps: Props, prevState: State, ) { const { listData, itemToID, itemToMeasureKey, itemToDummy, mergeItemWithHeight, allHeightsMeasured, ...rest } = this.props; const { listData: prevListData, itemToID: prevItemToID, itemToMeasureKey: prevItemToMeasureKey, itemToDummy: prevItemToDummy, mergeItemWithHeight: prevMergeItemWithHeight, allHeightsMeasured: prevAllHeightsMeasured, ...prevRest } = prevProps; const restShallowEqual = shallowequal(rest, prevRest); const measurementJustCompleted = this.state.currentlyMeasuring.length === 0 && prevState.currentlyMeasuring.length !== 0; let incrementIteration = false; const nextMeasuredHeights = new Map(this.state.measuredHeights); let measuredHeightsChanged = false; const nextMeasurableItems = new Map(this.state.measurableItems); let measurableItemsChanged = false; const nextUnmeasurableItems = new Map(this.state.unmeasurableItems); let unmeasurableItemsChanged = false; if ( itemToMeasureKey !== prevItemToMeasureKey || itemToDummy !== prevItemToDummy ) { incrementIteration = true; nextMeasuredHeights.clear(); measuredHeightsChanged = true; } if ( itemToID !== prevItemToID || itemToMeasureKey !== prevItemToMeasureKey || itemToDummy !== prevItemToDummy || mergeItemWithHeight !== prevMergeItemWithHeight || !restShallowEqual ) { if (nextMeasurableItems.size > 0) { nextMeasurableItems.clear(); measurableItemsChanged = true; } } if ( itemToID !== prevItemToID || itemToMeasureKey !== prevItemToMeasureKey || mergeItemWithHeight !== prevMergeItemWithHeight || !restShallowEqual ) { if (nextUnmeasurableItems.size > 0) { nextUnmeasurableItems.clear(); unmeasurableItemsChanged = true; } } if ( measurementJustCompleted || listData !== prevListData || measuredHeightsChanged || measurableItemsChanged || unmeasurableItemsChanged ) { const currentMeasurableItems = new Map(); const currentUnmeasurableItems = new Map(); if (listData) { for (const item of listData) { const id = itemToID(item); const measureKey = itemToMeasureKey(item); if (measureKey !== null && measureKey !== undefined) { currentMeasurableItems.set(id, item); } else { currentUnmeasurableItems.set(id, item); } } } for (const [id, { item }] of nextMeasurableItems) { const currentItem = currentMeasurableItems.get(id); if (!currentItem) { measurableItemsChanged = true; nextMeasurableItems.delete(id); } else if (currentItem !== item) { measurableItemsChanged = true; const measureKey = itemToMeasureKey(currentItem); if (measureKey === null || measureKey === undefined) { nextMeasurableItems.delete(id); continue; } const height = nextMeasuredHeights.get(measureKey); if (height === null || height === undefined) { nextMeasurableItems.delete(id); continue; } const mergedItem = mergeItemWithHeight(currentItem, height); nextMeasurableItems.set(id, { item: currentItem, mergedItem }); } } for (const [id, item] of currentMeasurableItems) { if (nextMeasurableItems.has(id)) { continue; } const measureKey = itemToMeasureKey(item); if (measureKey === null || measureKey === undefined) { continue; } const height = nextMeasuredHeights.get(measureKey); if (height === null || height === undefined) { continue; } const mergedItem = mergeItemWithHeight(item, height); nextMeasurableItems.set(id, { item, mergedItem }); measurableItemsChanged = true; } for (const [id, { item }] of nextUnmeasurableItems) { const currentItem = currentUnmeasurableItems.get(id); if (!currentItem) { unmeasurableItemsChanged = true; nextUnmeasurableItems.delete(id); } else if (currentItem !== item) { unmeasurableItemsChanged = true; const measureKey = itemToMeasureKey(currentItem); if (measureKey !== null && measureKey !== undefined) { nextUnmeasurableItems.delete(id); continue; } const mergedItem = mergeItemWithHeight(currentItem, undefined); nextUnmeasurableItems.set(id, { item: currentItem, mergedItem }); } } for (const [id, item] of currentUnmeasurableItems) { if (nextUnmeasurableItems.has(id)) { continue; } const measureKey = itemToMeasureKey(item); if (measureKey !== null && measureKey !== undefined) { continue; } const mergedItem = mergeItemWithHeight(item, undefined); nextUnmeasurableItems.set(id, { item, mergedItem }); unmeasurableItemsChanged = true; } } const stateUpdate = {}; if (incrementIteration) { stateUpdate.iteration = this.state.iteration + 1; } if (measuredHeightsChanged) { stateUpdate.measuredHeights = nextMeasuredHeights; } if (measurableItemsChanged) { stateUpdate.measurableItems = nextMeasurableItems; } if (unmeasurableItemsChanged) { stateUpdate.unmeasurableItems = nextUnmeasurableItems; } if (Object.keys(stateUpdate).length > 0) { this.setState(stateUpdate); } if (measurementJustCompleted || !shallowequal(this.props, prevProps)) { this.triggerCallback( nextMeasurableItems, nextUnmeasurableItems, measurementJustCompleted, ); } } onContainerLayout = (event: LayoutEvent) => { const { width, height } = event.nativeEvent.layout; if (width > height) { // We currently only use NodeHeightMeasurer on interfaces that are // portrait-locked. If we expand beyond that we'll need to rethink this return; } if (this.containerWidth === undefined) { this.containerWidth = width; } else if (this.containerWidth !== width) { this.containerWidth = width; this.setState((innerPrevState) => ({ iteration: innerPrevState.iteration + 1, measuredHeights: new Map(), measurableItems: new Map(), })); } }; onDummyLayout(measureKey: string, iteration: number, event: LayoutEvent) { if (iteration !== this.state.iteration) { return; } const { height } = event.nativeEvent.layout; this.state.measuredHeights.set(measureKey, height); this.possiblyIssueNewBatch(); } render() { const { currentlyMeasuring, iteration } = this.state; const dummies = currentlyMeasuring.map(({ measureKey, dummy }) => { const { children } = dummy.props; const style = [dummy.props.style, styles.dummy]; const onLayout = (event) => this.onDummyLayout(measureKey, iteration, event); const node = React.cloneElement(dummy, { style, onLayout, children, }); return {node}; }); return {dummies}; } } const styles = StyleSheet.create({ dummy: { opacity: 0, position: 'absolute', }, }); export default NodeHeightMeasurer; diff --git a/native/components/pencil-icon.react.js b/native/components/pencil-icon.react.js index f1cff2efa..e3f2997fa 100644 --- a/native/components/pencil-icon.react.js +++ b/native/components/pencil-icon.react.js @@ -1,33 +1,31 @@ // @flow -import type { AppState } from '../redux/redux-setup'; - +import { connect } from 'lib/utils/redux-utils'; import * as React from 'react'; import { Platform } from 'react-native'; import Icon from 'react-native-vector-icons/FontAwesome'; -import { connect } from 'lib/utils/redux-utils'; - +import type { AppState } from '../redux/redux-setup'; import { styleSelector } from '../themes/colors'; type Props = {| // Redux state styles: typeof styles, |}; function PencilIcon(props: Props) { return ; } const styles = { editIcon: { color: 'link', lineHeight: 20, paddingTop: Platform.select({ android: 1, default: 0 }), textAlign: 'right', }, }; const stylesSelector = styleSelector(styles); export default connect((state: AppState) => ({ styles: stylesSelector(state), }))(PencilIcon); diff --git a/native/components/search.react.js b/native/components/search.react.js index 7ef61b374..ec74acbef 100644 --- a/native/components/search.react.js +++ b/native/components/search.react.js @@ -1,136 +1,134 @@ // @flow -import type { AppState } from '../redux/redux-setup'; -import type { ViewStyle } from '../types/styles'; - -import * as React from 'react'; +import { isLoggedIn } from 'lib/selectors/user-selectors'; +import { connect } from 'lib/utils/redux-utils'; import PropTypes from 'prop-types'; +import * as React from 'react'; import { View, ViewPropTypes, TouchableOpacity, TextInput } from 'react-native'; import Icon from 'react-native-vector-icons/FontAwesome'; -import { connect } from 'lib/utils/redux-utils'; -import { isLoggedIn } from 'lib/selectors/user-selectors'; - +import type { AppState } from '../redux/redux-setup'; import { type Colors, colorsPropType, colorsSelector, styleSelector, } from '../themes/colors'; +import type { ViewStyle } from '../types/styles'; type Props = {| ...React.ElementConfig, searchText: string, onChangeText: (searchText: string) => void, containerStyle?: ViewStyle, textInputRef?: React.Ref, // Redux state colors: Colors, styles: typeof styles, loggedIn: boolean, |}; class Search extends React.PureComponent { static propTypes = { searchText: PropTypes.string.isRequired, onChangeText: PropTypes.func.isRequired, containerStyle: ViewPropTypes.style, textInputRef: PropTypes.func, colors: colorsPropType.isRequired, styles: PropTypes.objectOf(PropTypes.object).isRequired, loggedIn: PropTypes.bool.isRequired, }; componentDidUpdate(prevProps: Props) { if (!this.props.loggedIn && prevProps.loggedIn) { this.clearSearch(); } } render() { const { searchText, onChangeText, containerStyle, textInputRef, colors, styles, loggedIn, ...rest } = this.props; const { listSearchIcon: iconColor } = colors; let clearSearchInputIcon = null; if (searchText) { clearSearchInputIcon = ( ); } const textInputProps: React.ElementProps = { style: styles.searchInput, value: searchText, onChangeText: onChangeText, placeholderTextColor: iconColor, returnKeyType: 'go', }; return ( {clearSearchInputIcon} ); } clearSearch = () => { this.props.onChangeText(''); }; } const styles = { search: { alignItems: 'center', backgroundColor: 'listSearchBackground', borderRadius: 6, flexDirection: 'row', paddingLeft: 14, paddingRight: 12, paddingVertical: 6, }, searchInput: { color: 'listForegroundLabel', flex: 1, fontSize: 16, marginLeft: 8, marginVertical: 0, padding: 0, borderBottomColor: 'transparent', }, }; const stylesSelector = styleSelector(styles); const ConnectedSearch = connect((state: AppState) => ({ colors: colorsSelector(state), styles: stylesSelector(state), loggedIn: isLoggedIn(state), }))(Search); type ConnectedProps = $Diff< Props, {| colors: Colors, styles: typeof styles, loggedIn: boolean, |}, >; export default React.forwardRef( function ForwardedConnectedSearch( props: ConnectedProps, ref: React.Ref, ) { return ; }, ); diff --git a/native/components/single-line.react.js b/native/components/single-line.react.js index fbe37b19c..86d410e4b 100644 --- a/native/components/single-line.react.js +++ b/native/components/single-line.react.js @@ -1,21 +1,20 @@ // @flow +import { firstLine } from 'lib/utils/string-utils'; import * as React from 'react'; import { Text } from 'react-native'; -import { firstLine } from 'lib/utils/string-utils'; - type Props = {| ...React.ElementConfig, children: ?string, |}; function SingleLine(props: Props) { const text = firstLine(props.children); return ( {text} ); } export { SingleLine }; diff --git a/native/components/swipeable.js b/native/components/swipeable.js index be15c2b63..1a4d343d9 100644 --- a/native/components/swipeable.js +++ b/native/components/swipeable.js @@ -1,133 +1,132 @@ // @flow -import type { AppState } from '../redux/redux-setup'; - +import { connect } from 'lib/utils/redux-utils'; +import PropTypes from 'prop-types'; import * as React from 'react'; import { Animated, View } from 'react-native'; -import PropTypes from 'prop-types'; import SwipeableComponent from 'react-native-gesture-handler/Swipeable'; -import { connect } from 'lib/utils/redux-utils'; - -import Button from './button.react'; +import type { AppState } from '../redux/redux-setup'; import { type Colors, colorsPropType, colorsSelector, styleSelector, } from '../themes/colors'; +import Button from './button.react'; + type Props = { +buttonWidth: number, +rightActions: $ReadOnlyArray<{| +key: string, +onPress: () => mixed, +color: ?string, +content: React.Node, |}>, +onSwipeableRightWillOpen?: () => void, +innerRef: {| current: ?SwipeableComponent, |}, +children?: React.Node, // Redux state +windowWidth: number, +colors: Colors, +styles: typeof styles, ... }; class Swipeable extends React.PureComponent { static propTypes = { buttonWidth: PropTypes.number.isRequired, rightActions: PropTypes.arrayOf( PropTypes.exact({ key: PropTypes.string.isRequired, onPress: PropTypes.func.isRequired, color: PropTypes.string, content: PropTypes.node.isRequired, }), ), onSwipeableRightWillOpen: PropTypes.func, innerRef: PropTypes.exact({ current: PropTypes.instanceOf(SwipeableComponent), }), children: PropTypes.node, windowWidth: PropTypes.number.isRequired, colors: colorsPropType.isRequired, styles: PropTypes.objectOf(PropTypes.object).isRequired, }; static defaultProps = { rightActions: [], }; renderRightActions = (progress) => { const actions = this.props.rightActions.map( ({ key, content, color, onPress }, i) => { const translation = progress.interpolate({ inputRange: [0, 1], outputRange: [ (this.props.rightActions.length - i) * this.props.buttonWidth, 0, ], }); return ( ); }, ); return {actions}; }; render() { return ( {this.props.children} ); } } const styles = { action: { height: '100%', alignItems: 'center', justifyContent: 'center', }, actionsContainer: { flexDirection: 'row', }, }; const stylesSelector = styleSelector(styles); export default connect((state: AppState) => ({ windowWidth: state.dimensions.width, colors: colorsSelector(state), styles: stylesSelector(state), }))(Swipeable); diff --git a/native/components/tag-input.react.js b/native/components/tag-input.react.js index 12f7b8238..4795955ed 100644 --- a/native/components/tag-input.react.js +++ b/native/components/tag-input.react.js @@ -1,493 +1,491 @@ // @flow -import type { ViewStyle, TextStyle } from '../types/styles'; -import type { AppState } from '../redux/redux-setup'; -import type { LayoutEvent } from '../types/react-native'; - -import * as React from 'react'; +import invariant from 'invariant'; +import { connect } from 'lib/utils/redux-utils'; import PropTypes from 'prop-types'; +import * as React from 'react'; import { View, Text, TextInput, StyleSheet, TouchableOpacity, TouchableWithoutFeedback, ScrollView, ViewPropTypes, Platform, } from 'react-native'; -import invariant from 'invariant'; - -import { connect } from 'lib/utils/redux-utils'; +import type { AppState } from '../redux/redux-setup'; import { type Colors, colorsPropType, colorsSelector } from '../themes/colors'; +import type { LayoutEvent } from '../types/react-native'; +import type { ViewStyle, TextStyle } from '../types/styles'; type Props = {| /** * An array of tags, which can be any type, as long as labelExtractor below * can extract a string from it. */ value: $ReadOnlyArray, /** * A handler to be called when array of tags change. */ onChange: (items: $ReadOnlyArray) => void, /** * Function to extract string value for label from item */ labelExtractor: (tagData: T) => string, /** * The text currently being displayed in the TextInput following the list of * tags. */ text: string, /** * This callback gets called when the user in the TextInput. The caller should * update the text prop when this is called if they want to access input. */ onChangeText: (text: string) => void, /** * If `true`, text and tags are not editable. The default value is `false`. */ disabled?: boolean, /** * Background color of tags */ tagColor?: string, /** * Text color of tags */ tagTextColor?: string, /** * Styling override for container surrounding tag text */ tagContainerStyle?: ViewStyle, /** * Styling override for tag's text component */ tagTextStyle?: TextStyle, /** * Color of text input */ inputColor?: string, /** * Any misc. TextInput props (autoFocus, placeholder, returnKeyType, etc.) */ inputProps?: React.ElementConfig, /** * Min height of the tag input on screen */ minHeight: number, /** * Max height of the tag input on screen (will scroll if max height reached) */ maxHeight: number, /** * Callback that gets passed the new component height when it changes */ onHeightChange?: (height: number) => void, /** * inputWidth if text === "". we want this number explicitly because if we're * forced to measure the component, there can be a short jump between the old * value and the new value, which looks sketchy. */ defaultInputWidth: number, innerRef?: (tagInput: ?TagInput) => void, // Redux state windowWidth: number, colors: Colors, |}; type State = {| wrapperHeight: number, contentHeight: number, wrapperWidth: number, spaceLeft: number, |}; class TagInput extends React.PureComponent, State> { static propTypes = { value: PropTypes.array.isRequired, onChange: PropTypes.func.isRequired, labelExtractor: PropTypes.func.isRequired, text: PropTypes.string.isRequired, onChangeText: PropTypes.func.isRequired, tagColor: PropTypes.string, tagTextColor: PropTypes.string, tagContainerStyle: ViewPropTypes.style, tagTextStyle: Text.propTypes.style, inputColor: PropTypes.string, inputProps: PropTypes.shape(TextInput.propTypes), minHeight: PropTypes.number, maxHeight: PropTypes.number, onHeightChange: PropTypes.func, defaultInputWidth: PropTypes.number, innerRef: PropTypes.func, windowWidth: PropTypes.number.isRequired, colors: colorsPropType.isRequired, }; // scroll to bottom scrollViewHeight = 0; scrollToBottomAfterNextScrollViewLayout = false; // refs tagInput: ?React.ElementRef = null; scrollView: ?React.ElementRef = null; lastChange: ?{| time: number, prevText: string |}; static defaultProps = { minHeight: 30, maxHeight: 75, defaultInputWidth: 90, }; constructor(props: Props) { super(props); this.state = { wrapperHeight: 30, // was wrapperHeight: 36, contentHeight: 0, wrapperWidth: props.windowWidth, spaceLeft: 0, }; } componentDidMount() { if (this.props.innerRef) { this.props.innerRef(this); } } componentWillUnmount() { if (this.props.innerRef) { this.props.innerRef(null); } } static getDerivedStateFromProps(props: Props, state: State) { const wrapperHeight = Math.max( Math.min(props.maxHeight, state.contentHeight), props.minHeight, ); return { wrapperHeight }; } componentDidUpdate(prevProps: Props, prevState: State) { if ( this.props.onHeightChange && this.state.wrapperHeight !== prevState.wrapperHeight ) { this.props.onHeightChange(this.state.wrapperHeight); } } measureWrapper = (event: LayoutEvent) => { const wrapperWidth = event.nativeEvent.layout.width; if (wrapperWidth !== this.state.wrapperWidth) { this.setState({ wrapperWidth }); } }; onChangeText = (text: string) => { this.lastChange = { time: Date.now(), prevText: this.props.text }; this.props.onChangeText(text); }; onBlur = ( event: $ReadOnly<{ nativeEvent: $ReadOnly<{ target: number }> }>, ) => { invariant(Platform.OS === 'ios', 'only iOS gets text on TextInput.onBlur'); const nativeEvent: $ReadOnly<{ target: number, text: string, }> = (event.nativeEvent: any); this.onChangeText(nativeEvent.text); }; onKeyPress = ( event: $ReadOnly<{ nativeEvent: $ReadOnly<{ key: string }> }>, ) => { const { lastChange } = this; let { text } = this.props; if ( Platform.OS === 'android' && lastChange !== null && lastChange !== undefined && Date.now() - lastChange.time < 150 ) { text = lastChange.prevText; } if (text !== '' || event.nativeEvent.key !== 'Backspace') { return; } const tags = [...this.props.value]; tags.pop(); this.props.onChange(tags); this.focus(); }; focus = () => { invariant(this.tagInput, 'should be set'); this.tagInput.focus(); }; removeIndex = (index: number) => { const tags = [...this.props.value]; tags.splice(index, 1); this.props.onChange(tags); }; scrollToBottom = () => { const scrollView = this.scrollView; invariant( scrollView, 'this.scrollView ref should exist before scrollToBottom called', ); scrollView.scrollToEnd(); }; render() { const tagColor = this.props.tagColor || this.props.colors.modalSubtext; const tagTextColor = this.props.tagTextColor || this.props.colors.modalForegroundLabel; const inputColor = this.props.inputColor || this.props.colors.modalForegroundLabel; const placeholderColor = this.props.colors.modalForegroundTertiaryLabel; const tags = this.props.value.map((tag, index) => ( )); let inputWidth; if (this.props.text === '') { inputWidth = this.props.defaultInputWidth; } else if (this.state.spaceLeft >= 100) { inputWidth = this.state.spaceLeft - 10; } else { inputWidth = this.state.wrapperWidth; } const defaultTextInputProps: React.ElementConfig = { blurOnSubmit: false, style: [ styles.textInput, { width: inputWidth, color: inputColor, }, ], autoCapitalize: 'none', autoCorrect: false, placeholder: 'Start typing', placeholderTextColor: placeholderColor, returnKeyType: 'done', keyboardType: 'default', }; const textInputProps: React.ElementConfig = { ...defaultTextInputProps, ...this.props.inputProps, // should not be overridden onKeyPress: this.onKeyPress, value: this.props.text, onBlur: Platform.OS === 'ios' ? this.onBlur : undefined, onChangeText: this.onChangeText, editable: !this.props.disabled, }; return ( {tags} ); } tagInputRef = (tagInput: ?React.ElementRef) => { this.tagInput = tagInput; }; scrollViewRef = (scrollView: ?React.ElementRef) => { this.scrollView = scrollView; }; onScrollViewContentSizeChange = (w: number, h: number) => { const oldContentHeight = this.state.contentHeight; if (h === oldContentHeight) { return; } let callback; if (h > oldContentHeight) { callback = () => { if (this.scrollViewHeight === this.props.maxHeight) { this.scrollToBottom(); } else { this.scrollToBottomAfterNextScrollViewLayout = true; } }; } this.setState({ contentHeight: h }, callback); }; onScrollViewLayout = (event: LayoutEvent) => { this.scrollViewHeight = event.nativeEvent.layout.height; if (this.scrollToBottomAfterNextScrollViewLayout) { this.scrollToBottom(); this.scrollToBottomAfterNextScrollViewLayout = false; } }; onLayoutLastTag = (endPosOfTag: number) => { const margin = 3; const spaceLeft = this.state.wrapperWidth - endPosOfTag - margin - 10; if (spaceLeft !== this.state.spaceLeft) { this.setState({ spaceLeft }); } }; } type TagProps = {| index: number, label: string, isLastTag: boolean, onLayoutLastTag: (endPosOfTag: number) => void, removeIndex: (index: number) => void, tagColor: string, tagTextColor: string, tagContainerStyle?: ViewStyle, tagTextStyle?: TextStyle, disabled?: boolean, |}; class Tag extends React.PureComponent { static propTypes = { index: PropTypes.number.isRequired, label: PropTypes.string.isRequired, isLastTag: PropTypes.bool.isRequired, onLayoutLastTag: PropTypes.func.isRequired, removeIndex: PropTypes.func.isRequired, tagColor: PropTypes.string.isRequired, tagTextColor: PropTypes.string.isRequired, tagContainerStyle: ViewPropTypes.style, tagTextStyle: Text.propTypes.style, }; curPos: ?number = null; componentDidUpdate(prevProps: TagProps) { if ( !prevProps.isLastTag && this.props.isLastTag && this.curPos !== null && this.curPos !== undefined ) { this.props.onLayoutLastTag(this.curPos); } } render() { return ( {this.props.label}  × ); } onPress = () => { this.props.removeIndex(this.props.index); }; onLayoutLastTag = (event: LayoutEvent) => { const layout = event.nativeEvent.layout; this.curPos = layout.width + layout.x; if (this.props.isLastTag) { this.props.onLayoutLastTag(this.curPos); } }; } const styles = StyleSheet.create({ tag: { borderRadius: 2, justifyContent: 'center', marginBottom: 3, marginRight: 3, paddingHorizontal: 6, paddingVertical: 2, }, tagInputContainer: { flex: 1, flexDirection: 'row', flexWrap: 'wrap', }, tagInputContainerScroll: { flex: 1, }, tagText: { fontSize: 16, margin: 0, padding: 0, }, textInput: { + borderBottomColor: 'transparent', flex: 0.6, fontSize: 16, height: 24, marginBottom: 3, marginHorizontal: 0, marginTop: 3, padding: 0, - borderBottomColor: 'transparent', }, textInputContainer: {}, wrapper: {}, }); export default connect((state: AppState) => ({ windowWidth: state.dimensions.width, colors: colorsSelector(state), }))(TagInput); diff --git a/native/components/thread-icon.react.js b/native/components/thread-icon.react.js index ea82e9a0c..bbebc0f81 100644 --- a/native/components/thread-icon.react.js +++ b/native/components/thread-icon.react.js @@ -1,40 +1,39 @@ // @flow import { threadTypes, type ThreadType } from 'lib/types/thread-types'; - import * as React from 'react'; import { StyleSheet } from 'react-native'; -import MaterialIcon from 'react-native-vector-icons/MaterialIcons'; import EntypoIcon from 'react-native-vector-icons/Entypo'; +import MaterialIcon from 'react-native-vector-icons/MaterialIcons'; type Props = {| +threadType: ThreadType, +color: string, |}; function ThreadIcon(props: Props) { const { threadType, color } = props; if (threadType === threadTypes.CHAT_SECRET) { return ; } else if (threadType === threadTypes.SIDEBAR) { return ( ); } else if (threadType === threadTypes.PERSONAL) { return ; } else { return ; } } const styles = StyleSheet.create({ sidebarIcon: { paddingTop: 2, }, }); export default ThreadIcon; diff --git a/native/components/thread-list-thread.react.js b/native/components/thread-list-thread.react.js index a9341926c..a572299c7 100644 --- a/native/components/thread-list-thread.react.js +++ b/native/components/thread-list-thread.react.js @@ -1,85 +1,83 @@ // @flow -import type { ViewStyle, TextStyle } from '../types/styles'; -import type { AppState } from '../redux/redux-setup'; - import { type ThreadInfo, threadInfoPropType } from 'lib/types/thread-types'; - -import * as React from 'react'; +import { connect } from 'lib/utils/redux-utils'; import PropTypes from 'prop-types'; +import * as React from 'react'; import { Text, ViewPropTypes } from 'react-native'; -import { connect } from 'lib/utils/redux-utils'; - -import Button from './button.react'; -import ColorSplotch from './color-splotch.react'; +import type { AppState } from '../redux/redux-setup'; import { type Colors, colorsPropType, colorsSelector, styleSelector, } from '../themes/colors'; +import type { ViewStyle, TextStyle } from '../types/styles'; + +import Button from './button.react'; +import ColorSplotch from './color-splotch.react'; import { SingleLine } from './single-line.react'; type Props = {| threadInfo: ThreadInfo, onSelect: (threadID: string) => void, style?: ViewStyle, textStyle?: TextStyle, // Redux state colors: Colors, styles: typeof styles, |}; class ThreadListThread extends React.PureComponent { static propTypes = { threadInfo: threadInfoPropType.isRequired, onSelect: PropTypes.func.isRequired, style: ViewPropTypes.style, textStyle: Text.propTypes.style, colors: colorsPropType.isRequired, styles: PropTypes.objectOf(PropTypes.object).isRequired, }; render() { const { modalIosHighlightUnderlay: underlayColor } = this.props.colors; return ( ); } onSelect = () => { this.props.onSelect(this.props.threadInfo.id); }; } const styles = { button: { alignItems: 'center', flexDirection: 'row', paddingLeft: 13, }, text: { color: 'modalForegroundLabel', fontSize: 16, paddingLeft: 9, paddingRight: 12, paddingVertical: 6, }, }; const stylesSelector = styleSelector(styles); export default connect((state: AppState) => ({ colors: colorsSelector(state), styles: stylesSelector(state), }))(ThreadListThread); diff --git a/native/components/thread-list.react.js b/native/components/thread-list.react.js index 0eb71a4ce..2dba67dc3 100644 --- a/native/components/thread-list.react.js +++ b/native/components/thread-list.react.js @@ -1,155 +1,154 @@ // @flow -import type { ViewStyle, TextStyle } from '../types/styles'; +import invariant from 'invariant'; +import SearchIndex from 'lib/shared/search-index'; import { type ThreadInfo, threadInfoPropType } from 'lib/types/thread-types'; -import type { AppState } from '../redux/redux-setup'; - -import * as React from 'react'; +import { connect } from 'lib/utils/redux-utils'; import PropTypes from 'prop-types'; +import * as React from 'react'; import { FlatList, ViewPropTypes, Text, TextInput } from 'react-native'; -import invariant from 'invariant'; import { createSelector } from 'reselect'; -import SearchIndex from 'lib/shared/search-index'; -import { connect } from 'lib/utils/redux-utils'; - -import ThreadListThread from './thread-list-thread.react'; +import type { AppState } from '../redux/redux-setup'; import { styleSelector, type IndicatorStyle, indicatorStylePropType, indicatorStyleSelector, } from '../themes/colors'; -import Search from './search.react'; +import type { ViewStyle, TextStyle } from '../types/styles'; import { waitForModalInputFocus } from '../utils/timers'; +import Search from './search.react'; +import ThreadListThread from './thread-list-thread.react'; + type Props = {| threadInfos: $ReadOnlyArray, onSelect: (threadID: string) => void, itemStyle?: ViewStyle, itemTextStyle?: TextStyle, searchIndex?: SearchIndex, // Redux state styles: typeof styles, indicatorStyle: IndicatorStyle, |}; type State = {| searchText: string, searchResults: Set, |}; type PropsAndState = {| ...Props, ...State |}; class ThreadList extends React.PureComponent { static propTypes = { threadInfos: PropTypes.arrayOf(threadInfoPropType).isRequired, onSelect: PropTypes.func.isRequired, itemStyle: ViewPropTypes.style, itemTextStyle: Text.propTypes.style, searchIndex: PropTypes.instanceOf(SearchIndex), styles: PropTypes.objectOf(PropTypes.object).isRequired, indicatorStyle: indicatorStylePropType.isRequired, }; state: State = { searchText: '', searchResults: new Set(), }; textInput: ?React.ElementRef; listDataSelector = createSelector( (propsAndState: PropsAndState) => propsAndState.threadInfos, (propsAndState: PropsAndState) => propsAndState.searchText, (propsAndState: PropsAndState) => propsAndState.searchResults, (propsAndState: PropsAndState) => propsAndState.itemStyle, (propsAndState: PropsAndState) => propsAndState.itemTextStyle, ( threadInfos: $ReadOnlyArray, text: string, searchResults: Set, ) => text ? threadInfos.filter((threadInfo) => searchResults.has(threadInfo.id)) : // We spread to make sure the result of this selector updates when // any input param (namely itemStyle or itemTextStyle) changes [...threadInfos], ); get listData() { return this.listDataSelector({ ...this.props, ...this.state }); } render() { let searchBar = null; if (this.props.searchIndex) { searchBar = ( ); } return ( {searchBar} ); } static keyExtractor(threadInfo: ThreadInfo) { return threadInfo.id; } renderItem = (row: { item: ThreadInfo }) => { return ( ); }; static getItemLayout(data: ?$ReadOnlyArray, index: number) { return { length: 24, offset: 24 * index, index }; } onChangeSearchText = (searchText: string) => { invariant(this.props.searchIndex, 'should be set'); const results = this.props.searchIndex.getSearchResults(searchText); this.setState({ searchText, searchResults: new Set(results) }); }; searchRef = async (textInput: ?React.ElementRef) => { this.textInput = textInput; if (!textInput) { return; } await waitForModalInputFocus(); if (this.textInput) { this.textInput.focus(); } }; } const styles = { search: { marginBottom: 8, }, }; const stylesSelector = styleSelector(styles); export default connect((state: AppState) => ({ styles: stylesSelector(state), indicatorStyle: indicatorStyleSelector(state), }))(ThreadList); diff --git a/native/components/thread-visibility.react.js b/native/components/thread-visibility.react.js index 176ad7429..f55f4bcff 100644 --- a/native/components/thread-visibility.react.js +++ b/native/components/thread-visibility.react.js @@ -1,45 +1,44 @@ // @flow import { threadTypes, type ThreadType } from 'lib/types/thread-types'; - import * as React from 'react'; import { View, Text, StyleSheet } from 'react-native'; import Icon from 'react-native-vector-icons/MaterialIcons'; type Props = {| +threadType: ThreadType, +color: string, |}; function ThreadVisibility(props: Props) { const { threadType, color } = props; const visLabelStyle = [styles.visibilityLabel, { color }]; if (threadType === threadTypes.CHAT_SECRET) { return ( Secret ); } else { return ( Open ); } } const styles = StyleSheet.create({ container: { alignItems: 'center', flexDirection: 'row', }, visibilityLabel: { fontSize: 16, fontWeight: 'bold', paddingLeft: 4, }, }); export default ThreadVisibility; diff --git a/native/components/user-list-user.react.js b/native/components/user-list-user.react.js index 0756df104..a3a49ec6d 100644 --- a/native/components/user-list-user.react.js +++ b/native/components/user-list-user.react.js @@ -1,106 +1,105 @@ // @flow -import type { TextStyle } from '../types/styles'; import { type UserListItem, userListItemPropType } from 'lib/types/user-types'; -import type { AppState } from '../redux/redux-setup'; - -import * as React from 'react'; +import { connect } from 'lib/utils/redux-utils'; import PropTypes from 'prop-types'; +import * as React from 'react'; import { Text, Platform, Alert } from 'react-native'; -import { connect } from 'lib/utils/redux-utils'; - -import Button from './button.react'; +import type { AppState } from '../redux/redux-setup'; import { type Colors, colorsPropType, colorsSelector, styleSelector, } from '../themes/colors'; +import type { TextStyle } from '../types/styles'; + +import Button from './button.react'; import { SingleLine } from './single-line.react'; // eslint-disable-next-line no-unused-vars const getUserListItemHeight = (item: UserListItem) => { // TODO consider parent thread notice return Platform.OS === 'ios' ? 31.5 : 33.5; }; type Props = {| userInfo: UserListItem, onSelect: (userID: string) => void, textStyle?: TextStyle, // Redux state colors: Colors, styles: typeof styles, |}; class UserListUser extends React.PureComponent { static propTypes = { userInfo: userListItemPropType.isRequired, onSelect: PropTypes.func.isRequired, textStyle: Text.propTypes.style, colors: colorsPropType.isRequired, styles: PropTypes.objectOf(PropTypes.object).isRequired, }; render() { const { userInfo } = this.props; let notice = null; if (userInfo.notice) { notice = {userInfo.notice}; } const { modalIosHighlightUnderlay: underlayColor } = this.props.colors; return ( ); } onSelect = () => { const { userInfo } = this.props; if (!userInfo.alertText) { this.props.onSelect(userInfo.id); return; } Alert.alert('Not a friend', userInfo.alertText, [{ text: 'OK' }], { cancelable: true, }); }; } const styles = { button: { alignItems: 'center', flexDirection: 'row', justifyContent: 'space-between', }, notice: { color: 'modalForegroundSecondaryLabel', fontStyle: 'italic', }, text: { color: 'modalForegroundLabel', flex: 1, fontSize: 16, paddingHorizontal: 12, paddingVertical: 6, }, }; const stylesSelector = styleSelector(styles); const WrappedUserListUser = connect((state: AppState) => ({ colors: colorsSelector(state), styles: stylesSelector(state), }))(UserListUser); export { WrappedUserListUser as UserListUser, getUserListItemHeight }; diff --git a/native/components/user-list.react.js b/native/components/user-list.react.js index 0b69e80cf..5a5b06a8e 100644 --- a/native/components/user-list.react.js +++ b/native/components/user-list.react.js @@ -1,80 +1,79 @@ // @flow -import type { TextStyle } from '../types/styles'; import { type UserListItem, userListItemPropType } from 'lib/types/user-types'; -import type { AppState } from '../redux/redux-setup'; - -import React from 'react'; +import { connect } from 'lib/utils/redux-utils'; +import _sum from 'lodash/fp/sum'; import PropTypes from 'prop-types'; +import React from 'react'; import { FlatList, Text } from 'react-native'; -import _sum from 'lodash/fp/sum'; -import { connect } from 'lib/utils/redux-utils'; - -import { UserListUser, getUserListItemHeight } from './user-list-user.react'; +import type { AppState } from '../redux/redux-setup'; import { type IndicatorStyle, indicatorStylePropType, indicatorStyleSelector, } from '../themes/colors'; +import type { TextStyle } from '../types/styles'; + +import { UserListUser, getUserListItemHeight } from './user-list-user.react'; type Props = { userInfos: $ReadOnlyArray, onSelect: (userID: string) => void, itemTextStyle?: TextStyle, // Redux state indicatorStyle: IndicatorStyle, }; class UserList extends React.PureComponent { static propTypes = { userInfos: PropTypes.arrayOf(userListItemPropType).isRequired, onSelect: PropTypes.func.isRequired, itemTextStyle: Text.propTypes.style, indicatorStyle: indicatorStylePropType.isRequired, }; render() { return ( ); } static keyExtractor(userInfo: UserListItem) { return userInfo.id; } renderItem = (row: { item: UserListItem }) => { return ( ); }; static getItemLayout(data: ?$ReadOnlyArray, index: number) { if (!data) { return { length: 0, offset: 0, index }; } const offset = _sum( data.filter((_, i) => i < index).map(getUserListItemHeight), ); const item = data[index]; const length = item ? getUserListItemHeight(item) : 0; return { length, offset, index }; } } export default connect((state: AppState) => ({ indicatorStyle: indicatorStyleSelector(state), }))(UserList); diff --git a/native/config.js b/native/config.js index e4074b476..8c8194683 100644 --- a/native/config.js +++ b/native/config.js @@ -1,20 +1,19 @@ // @flow -import { Platform } from 'react-native'; - import { registerConfig } from 'lib/utils/config'; +import { Platform } from 'react-native'; import { resolveInvalidatedCookie } from './account/resolve-invalidated-cookie'; import { persistConfig, codeVersion } from './redux/persist'; registerConfig({ resolveInvalidatedCookie, setCookieOnRequest: true, setSessionIDOnRequest: false, calendarRangeInactivityLimit: 15 * 60 * 1000, platformDetails: { platform: Platform.OS, codeVersion, stateVersion: persistConfig.version, }, }); diff --git a/native/connected-status-bar.react.js b/native/connected-status-bar.react.js index 9b134c710..c0d4f16db 100644 --- a/native/connected-status-bar.react.js +++ b/native/connected-status-bar.react.js @@ -1,60 +1,59 @@ // @flow -import type { AppState } from './redux/redux-setup'; +import { globalLoadingStatusSelector } from 'lib/selectors/loading-selectors'; import type { LoadingStatus } from 'lib/types/loading-types'; -import { type GlobalTheme, globalThemePropType } from './types/themes'; - +import { connect } from 'lib/utils/redux-utils'; +import PropTypes from 'prop-types'; import React from 'react'; import { StatusBar, Platform } from 'react-native'; -import PropTypes from 'prop-types'; -import { globalLoadingStatusSelector } from 'lib/selectors/loading-selectors'; -import { connect } from 'lib/utils/redux-utils'; +import type { AppState } from './redux/redux-setup'; +import { type GlobalTheme, globalThemePropType } from './types/themes'; type Props = {| barStyle?: 'default' | 'light-content' | 'dark-content', animated?: boolean, // Redux state globalLoadingStatus: LoadingStatus, activeTheme: ?GlobalTheme, |}; class ConnectedStatusBar extends React.PureComponent { static propTypes = { barStyle: PropTypes.oneOf(['default', 'light-content', 'dark-content']), animated: PropTypes.bool, globalLoadingStatus: PropTypes.string.isRequired, activeTheme: globalThemePropType, }; render() { const { barStyle: inBarStyle, activeTheme, globalLoadingStatus, ...statusBarProps } = this.props; let barStyle = inBarStyle; if (!barStyle) { if (Platform.OS !== 'android' && this.props.activeTheme === 'light') { barStyle = 'dark-content'; } else { barStyle = 'light-content'; } } const fetchingSomething = this.props.globalLoadingStatus === 'loading'; return ( ); } } export default connect((state: AppState) => ({ globalLoadingStatus: globalLoadingStatusSelector(state), activeTheme: state.globalThemeInfo.activeTheme, }))(ConnectedStatusBar); diff --git a/native/crash.react.js b/native/crash.react.js index fed2c235f..e3ac6cf55 100644 --- a/native/crash.react.js +++ b/native/crash.react.js @@ -1,269 +1,267 @@ // @flow +import Clipboard from '@react-native-community/clipboard'; +import invariant from 'invariant'; +import { sendReportActionTypes, sendReport } from 'lib/actions/report-actions'; +import { logOutActionTypes, logOut } from 'lib/actions/user-actions'; +import { preRequestUserStateSelector } from 'lib/selectors/account-selectors'; +import type { LogOutResult } from 'lib/types/account-types'; +import type { ErrorData } from 'lib/types/report-types'; import { type ClientReportCreationRequest, type ReportCreationResponse, reportTypes, } from 'lib/types/report-types'; -import type { DispatchActionPromise } from 'lib/utils/action-utils'; -import type { AppState } from './redux/redux-setup'; -import type { ErrorData } from 'lib/types/report-types'; -import type { LogOutResult } from 'lib/types/account-types'; import { type PreRequestUserState, preRequestUserStatePropType, } from 'lib/types/session-types'; - +import { actionLogger } from 'lib/utils/action-logger'; +import type { DispatchActionPromise } from 'lib/utils/action-utils'; +import { connect } from 'lib/utils/redux-utils'; +import { sanitizeAction, sanitizeState } from 'lib/utils/sanitization'; +import sleep from 'lib/utils/sleep'; +import _shuffle from 'lodash/fp/shuffle'; +import PropTypes from 'prop-types'; import * as React from 'react'; import { View, Text, Platform, StyleSheet, ScrollView, ActivityIndicator, } from 'react-native'; -import Icon from 'react-native-vector-icons/FontAwesome'; -import _shuffle from 'lodash/fp/shuffle'; import ExitApp from 'react-native-exit-app'; -import PropTypes from 'prop-types'; -import invariant from 'invariant'; -import Clipboard from '@react-native-community/clipboard'; - -import { connect } from 'lib/utils/redux-utils'; -import { sendReportActionTypes, sendReport } from 'lib/actions/report-actions'; -import sleep from 'lib/utils/sleep'; -import { actionLogger } from 'lib/utils/action-logger'; -import { logOutActionTypes, logOut } from 'lib/actions/user-actions'; -import { sanitizeAction, sanitizeState } from 'lib/utils/sanitization'; -import { preRequestUserStateSelector } from 'lib/selectors/account-selectors'; +import Icon from 'react-native-vector-icons/FontAwesome'; import Button from './components/button.react'; +import ConnectedStatusBar from './connected-status-bar.react'; import { persistConfig, codeVersion } from './redux/persist'; +import type { AppState } from './redux/redux-setup'; import { wipeAndExit } from './utils/crash-utils'; -import ConnectedStatusBar from './connected-status-bar.react'; const errorTitles = ['Oh no!!', 'Womp womp womp...']; type Props = { errorData: $ReadOnlyArray, // Redux state preRequestUserState: PreRequestUserState, // Redux dispatch functions dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs sendReport: ( request: ClientReportCreationRequest, ) => Promise, logOut: (preRequestUserState: PreRequestUserState) => Promise, }; type State = {| errorReportID: ?string, doneWaiting: boolean, |}; class Crash extends React.PureComponent { static propTypes = { errorData: PropTypes.arrayOf( PropTypes.shape({ error: PropTypes.object.isRequired, info: PropTypes.shape({ componentStack: PropTypes.string.isRequired, }), }), ).isRequired, preRequestUserState: preRequestUserStatePropType.isRequired, dispatchActionPromise: PropTypes.func.isRequired, sendReport: PropTypes.func.isRequired, logOut: PropTypes.func.isRequired, }; errorTitle = _shuffle(errorTitles)[0]; state: State = { errorReportID: null, doneWaiting: false, }; componentDidMount() { this.props.dispatchActionPromise(sendReportActionTypes, this.sendReport()); this.timeOut(); } async timeOut() { // If it takes more than 10s, give up and let the user exit await sleep(10000); this.setState({ doneWaiting: true }); } render() { const errorText = [...this.props.errorData] .reverse() .map((errorData) => errorData.error.message) .join('\n'); let crashID; if (this.state.errorReportID) { crashID = ( {this.state.errorReportID} ); } else { crashID = ; } const buttonStyle = { opacity: Number(this.state.doneWaiting) }; return ( {this.errorTitle} I'm sorry, but the app crashed. Crash report ID: {crashID} Here's some text that's probably not helpful: {errorText} ); } async sendReport() { const result = await this.props.sendReport({ type: reportTypes.ERROR, platformDetails: { platform: Platform.OS, codeVersion, stateVersion: persistConfig.version, }, errors: this.props.errorData.map((data) => ({ errorMessage: data.error.message, stack: data.error.stack, componentStack: data.info && data.info.componentStack, })), preloadedState: sanitizeState(actionLogger.preloadedState), currentState: sanitizeState(actionLogger.currentState), actions: actionLogger.actions.map(sanitizeAction), }); this.setState({ errorReportID: result.id, doneWaiting: true, }); } onPressKill = () => { if (!this.state.doneWaiting) { return; } ExitApp.exitApp(); }; onPressWipe = async () => { if (!this.state.doneWaiting) { return; } this.props.dispatchActionPromise(logOutActionTypes, this.logOutAndExit()); }; async logOutAndExit() { try { await this.props.logOut(this.props.preRequestUserState); } catch (e) {} await wipeAndExit(); } onCopyCrashReportID = () => { invariant(this.state.errorReportID, 'should be set'); Clipboard.setString(this.state.errorReportID); }; } const styles = StyleSheet.create({ button: { backgroundColor: '#FF0000', borderRadius: 5, marginHorizontal: 10, paddingHorizontal: 10, paddingVertical: 5, }, buttonText: { color: 'white', fontSize: 16, }, buttons: { flexDirection: 'row', }, container: { alignItems: 'center', backgroundColor: 'white', flex: 1, justifyContent: 'center', }, copyCrashReportIDButtonText: { color: '#036AFF', }, crashID: { flexDirection: 'row', paddingBottom: 12, paddingTop: 2, }, crashIDText: { color: 'black', paddingRight: 8, }, errorReportID: { flexDirection: 'row', height: 20, }, errorReportIDText: { color: 'black', paddingRight: 8, }, errorText: { color: 'black', fontFamily: Platform.select({ ios: 'Menlo', default: 'monospace', }), }, header: { color: 'black', fontSize: 24, paddingBottom: 24, }, scrollView: { flex: 1, marginBottom: 24, marginTop: 12, maxHeight: 200, paddingHorizontal: 50, }, text: { color: 'black', paddingBottom: 12, }, }); export default connect( (state: AppState) => ({ preRequestUserState: preRequestUserStateSelector(state), }), { sendReport, logOut }, )(Crash); diff --git a/native/error-boundary.react.js b/native/error-boundary.react.js index e9a3628f2..ca6be40c6 100644 --- a/native/error-boundary.react.js +++ b/native/error-boundary.react.js @@ -1,57 +1,56 @@ // @flow import type { ErrorInfo, ErrorData } from 'lib/types/report-types'; - import * as React from 'react'; import Crash from './crash.react'; let instance = null; const defaultHandler = global.ErrorUtils.getGlobalHandler(); global.ErrorUtils.setGlobalHandler((error) => { defaultHandler(error); if (instance) { instance.reportError(error); } }); type Props = {| children: React.Node, |}; type State = {| errorData: $ReadOnlyArray, |}; class ErrorBoundary extends React.PureComponent { state: State = { errorData: [], }; componentDidMount() { instance = this; } componentWillUnmount() { instance = null; } componentDidCatch(error: Error, info: ErrorInfo) { this.setState((prevState) => ({ errorData: [...prevState.errorData, { error, info }], })); } reportError(error: Error) { this.setState((prevState) => ({ errorData: [...prevState.errorData, { error }], })); } render() { if (this.state.errorData.length > 0) { return ; } return this.props.children; } } export default ErrorBoundary; diff --git a/native/index.js b/native/index.js index 7135b7f97..00b5820ed 100644 --- a/native/index.js +++ b/native/index.js @@ -1,12 +1,12 @@ // @flow import 'react-native-gesture-handler'; import './reactotron'; import './config'; import { AppRegistry } from 'react-native'; -import Root from './root.react'; import { name as appName } from './app.json'; +import Root from './root.react'; AppRegistry.registerComponent(appName, () => Root); diff --git a/native/input/input-state-container.react.js b/native/input/input-state-container.react.js index 840c26d88..4c163a3f0 100644 --- a/native/input/input-state-container.react.js +++ b/native/input/input-state-container.react.js @@ -1,1076 +1,1075 @@ // @flow -import type { AppState } from '../redux/redux-setup'; -import type { - DispatchActionPayload, - DispatchActionPromise, -} from 'lib/utils/action-utils'; +import invariant from 'invariant'; +import { + createLocalMessageActionType, + sendMultimediaMessageActionTypes, + sendMultimediaMessage, + sendTextMessageActionTypes, + sendTextMessage, +} from 'lib/actions/message-actions'; +import { queueReportsActionType } from 'lib/actions/report-actions'; +import { + uploadMultimedia, + updateMultimediaMessageMediaActionType, + type MultimediaUploadCallbacks, + type MultimediaUploadExtras, +} from 'lib/actions/upload-actions'; +import { pathFromURI } from 'lib/media/file-utils'; +import { videoDurationLimit } from 'lib/media/video-utils'; +import { + createLoadingStatusSelector, + combineLoadingStatuses, +} from 'lib/selectors/loading-selectors'; +import { createMediaMessageInfo } from 'lib/shared/message-utils'; +import { isStaff } from 'lib/shared/user-utils'; import type { UploadMultimediaResult, Media, NativeMediaSelection, MediaMissionResult, MediaMission, } from 'lib/types/media-types'; import { messageTypes, type RawMessageInfo, type RawMultimediaMessageInfo, type SendMessageResult, type SendMessagePayload, type RawImagesMessageInfo, type RawMediaMessageInfo, type RawTextMessageInfo, } from 'lib/types/message-types'; import { type MediaMissionReportCreationRequest, reportTypes, } from 'lib/types/report-types'; +import type { + DispatchActionPayload, + DispatchActionPromise, +} from 'lib/utils/action-utils'; +import { getConfig } from 'lib/utils/config'; +import { getMessageForException, cloneError } from 'lib/utils/errors'; import type { FetchJSONOptions, FetchJSONServerResponse, } from 'lib/utils/fetch-json'; - -import * as React from 'react'; +import { connect } from 'lib/utils/redux-utils'; import PropTypes from 'prop-types'; -import invariant from 'invariant'; -import { createSelector } from 'reselect'; -import * as Upload from 'react-native-background-upload'; +import * as React from 'react'; import { Platform } from 'react-native'; +import * as Upload from 'react-native-background-upload'; +import { createSelector } from 'reselect'; -import { connect } from 'lib/utils/redux-utils'; -import { - uploadMultimedia, - updateMultimediaMessageMediaActionType, - type MultimediaUploadCallbacks, - type MultimediaUploadExtras, -} from 'lib/actions/upload-actions'; -import { - createLocalMessageActionType, - sendMultimediaMessageActionTypes, - sendMultimediaMessage, - sendTextMessageActionTypes, - sendTextMessage, -} from 'lib/actions/message-actions'; -import { createMediaMessageInfo } from 'lib/shared/message-utils'; -import { queueReportsActionType } from 'lib/actions/report-actions'; -import { getConfig } from 'lib/utils/config'; -import { - createLoadingStatusSelector, - combineLoadingStatuses, -} from 'lib/selectors/loading-selectors'; -import { pathFromURI } from 'lib/media/file-utils'; -import { isStaff } from 'lib/shared/user-utils'; -import { videoDurationLimit } from 'lib/media/video-utils'; -import { getMessageForException, cloneError } from 'lib/utils/errors'; +import { disposeTempFile } from '../media/file-utils'; +import { processMedia } from '../media/media-utils'; +import { displayActionResultModal } from '../navigation/action-result-modal'; +import type { AppState } from '../redux/redux-setup'; import { InputStateContext, type PendingMultimediaUploads, } from './input-state'; -import { processMedia } from '../media/media-utils'; -import { displayActionResultModal } from '../navigation/action-result-modal'; -import { disposeTempFile } from '../media/file-utils'; let nextLocalUploadID = 0; function getNewLocalID() { return `localUpload${nextLocalUploadID++}`; } type SelectionWithID = {| selection: NativeMediaSelection, localID: string, |}; type CompletedUploads = { [localMessageID: string]: ?Set }; type Props = {| children: React.Node, // Redux state viewerID: ?string, nextLocalID: number, messageStoreMessages: { [id: string]: RawMessageInfo }, ongoingMessageCreation: boolean, hasWiFi: boolean, // Redux dispatch functions dispatchActionPayload: DispatchActionPayload, dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs uploadMultimedia: ( multimedia: Object, extras: MultimediaUploadExtras, callbacks: MultimediaUploadCallbacks, ) => Promise, sendMultimediaMessage: ( threadID: string, localID: string, mediaIDs: $ReadOnlyArray, ) => Promise, sendTextMessage: ( threadID: string, localID: string, text: string, ) => Promise, |}; type State = {| pendingUploads: PendingMultimediaUploads, |}; class InputStateContainer extends React.PureComponent { static propTypes = { children: PropTypes.node.isRequired, viewerID: PropTypes.string, nextLocalID: PropTypes.number.isRequired, messageStoreMessages: PropTypes.object.isRequired, ongoingMessageCreation: PropTypes.bool.isRequired, hasWiFi: PropTypes.bool.isRequired, dispatchActionPayload: PropTypes.func.isRequired, dispatchActionPromise: PropTypes.func.isRequired, uploadMultimedia: PropTypes.func.isRequired, sendMultimediaMessage: PropTypes.func.isRequired, sendTextMessage: PropTypes.func.isRequired, }; state: State = { pendingUploads: {}, }; sendCallbacks: Array<() => void> = []; activeURIs = new Map(); replyCallbacks: Array<(message: string) => void> = []; static getCompletedUploads(props: Props, state: State): CompletedUploads { const completedUploads = {}; for (let localMessageID in state.pendingUploads) { const messagePendingUploads = state.pendingUploads[localMessageID]; const rawMessageInfo = props.messageStoreMessages[localMessageID]; if (!rawMessageInfo) { continue; } invariant( rawMessageInfo.type === messageTypes.IMAGES || rawMessageInfo.type === messageTypes.MULTIMEDIA, `rawMessageInfo ${localMessageID} should be multimedia`, ); const completed = []; let allUploadsComplete = true; for (let localUploadID in messagePendingUploads) { let media; for (let singleMedia of rawMessageInfo.media) { if (singleMedia.id === localUploadID) { media = singleMedia; break; } } if (media) { allUploadsComplete = false; } else { completed.push(localUploadID); } } if (allUploadsComplete) { completedUploads[localMessageID] = null; } else if (completed.length > 0) { completedUploads[localMessageID] = new Set(completed); } } return completedUploads; } componentDidUpdate(prevProps: Props, prevState: State) { if (this.props.viewerID !== prevProps.viewerID) { this.setState({ pendingUploads: {} }); return; } const currentlyComplete = InputStateContainer.getCompletedUploads( this.props, this.state, ); const previouslyComplete = InputStateContainer.getCompletedUploads( prevProps, prevState, ); const newPendingUploads = {}; let pendingUploadsChanged = false; const readyMessageIDs = []; for (let localMessageID in this.state.pendingUploads) { const messagePendingUploads = this.state.pendingUploads[localMessageID]; const prevRawMessageInfo = prevProps.messageStoreMessages[localMessageID]; const rawMessageInfo = this.props.messageStoreMessages[localMessageID]; const completedUploadIDs = currentlyComplete[localMessageID]; const previouslyCompletedUploadIDs = previouslyComplete[localMessageID]; if (!rawMessageInfo && prevRawMessageInfo) { pendingUploadsChanged = true; continue; } else if (completedUploadIDs === null) { // All of this message's uploads have been completed newPendingUploads[localMessageID] = {}; if (previouslyCompletedUploadIDs !== null) { readyMessageIDs.push(localMessageID); pendingUploadsChanged = true; } continue; } else if (!completedUploadIDs) { // Nothing has been completed newPendingUploads[localMessageID] = messagePendingUploads; continue; } const newUploads = {}; let uploadsChanged = false; for (let localUploadID in messagePendingUploads) { if (!completedUploadIDs.has(localUploadID)) { newUploads[localUploadID] = messagePendingUploads[localUploadID]; } else if ( !previouslyCompletedUploadIDs || !previouslyCompletedUploadIDs.has(localUploadID) ) { uploadsChanged = true; } } if (uploadsChanged) { pendingUploadsChanged = true; newPendingUploads[localMessageID] = newUploads; } else { newPendingUploads[localMessageID] = messagePendingUploads; } } if (pendingUploadsChanged) { this.setState({ pendingUploads: newPendingUploads }); } for (let localMessageID of readyMessageIDs) { const rawMessageInfo = this.props.messageStoreMessages[localMessageID]; if (!rawMessageInfo) { continue; } invariant( rawMessageInfo.type === messageTypes.IMAGES || rawMessageInfo.type === messageTypes.MULTIMEDIA, `rawMessageInfo ${localMessageID} should be multimedia`, ); this.dispatchMultimediaMessageAction(rawMessageInfo); } } dispatchMultimediaMessageAction(messageInfo: RawMultimediaMessageInfo) { this.props.dispatchActionPromise( sendMultimediaMessageActionTypes, this.sendMultimediaMessageAction(messageInfo), undefined, messageInfo, ); } async sendMultimediaMessageAction( messageInfo: RawMultimediaMessageInfo, ): Promise { const { localID, threadID } = messageInfo; invariant( localID !== null && localID !== undefined, 'localID should be set', ); const mediaIDs = []; for (let { id } of messageInfo.media) { mediaIDs.push(id); } try { const result = await this.props.sendMultimediaMessage( threadID, localID, mediaIDs, ); return { localID, serverID: result.id, threadID, time: result.time, }; } catch (e) { const copy = cloneError(e); copy.localID = localID; copy.threadID = threadID; throw copy; } } inputStateSelector = createSelector( (state: State) => state.pendingUploads, (pendingUploads: PendingMultimediaUploads) => ({ pendingUploads, sendTextMessage: this.sendTextMessage, sendMultimediaMessage: this.sendMultimediaMessage, addReply: this.addReply, addReplyListener: this.addReplyListener, removeReplyListener: this.removeReplyListener, messageHasUploadFailure: this.messageHasUploadFailure, retryMultimediaMessage: this.retryMultimediaMessage, registerSendCallback: this.registerSendCallback, unregisterSendCallback: this.unregisterSendCallback, uploadInProgress: this.uploadInProgress, reportURIDisplayed: this.reportURIDisplayed, }), ); uploadInProgress = () => { if (this.props.ongoingMessageCreation) { return true; } for (let localMessageID in this.state.pendingUploads) { const messagePendingUploads = this.state.pendingUploads[localMessageID]; for (let localUploadID in messagePendingUploads) { const { failed } = messagePendingUploads[localUploadID]; if (!failed) { return true; } } } return false; }; sendTextMessage = (messageInfo: RawTextMessageInfo) => { this.sendCallbacks.forEach((callback) => callback()); this.props.dispatchActionPromise( sendTextMessageActionTypes, this.sendTextMessageAction(messageInfo), undefined, messageInfo, ); }; async sendTextMessageAction( messageInfo: RawTextMessageInfo, ): Promise { try { const { localID } = messageInfo; invariant( localID !== null && localID !== undefined, 'localID should be set', ); const result = await this.props.sendTextMessage( messageInfo.threadID, localID, messageInfo.text, ); return { localID, serverID: result.id, threadID: messageInfo.threadID, time: result.time, }; } catch (e) { const copy = cloneError(e); copy.localID = messageInfo.localID; copy.threadID = messageInfo.threadID; throw copy; } } sendMultimediaMessage = async ( threadID: string, selections: $ReadOnlyArray, ) => { this.sendCallbacks.forEach((callback) => callback()); const localMessageID = `local${this.props.nextLocalID}`; const selectionsWithIDs = selections.map((selection) => ({ selection, localID: getNewLocalID(), })); const pendingUploads = {}; for (let { localID } of selectionsWithIDs) { pendingUploads[localID] = { failed: null, progressPercent: 0, }; } this.setState( (prevState) => { return { pendingUploads: { ...prevState.pendingUploads, [localMessageID]: pendingUploads, }, }; }, () => { const creatorID = this.props.viewerID; invariant(creatorID, 'need viewer ID in order to send a message'); const media = selectionsWithIDs.map(({ localID, selection }) => { if (selection.step === 'photo_library') { return { id: localID, uri: selection.uri, type: 'photo', dimensions: selection.dimensions, localMediaSelection: selection, }; } else if (selection.step === 'photo_capture') { return { id: localID, uri: selection.uri, type: 'photo', dimensions: selection.dimensions, localMediaSelection: selection, }; } else if (selection.step === 'video_library') { return { id: localID, uri: selection.uri, type: 'video', dimensions: selection.dimensions, localMediaSelection: selection, loop: false, }; } invariant(false, `invalid selection ${JSON.stringify(selection)}`); }); const messageInfo = createMediaMessageInfo({ localID: localMessageID, threadID, creatorID, media, }); this.props.dispatchActionPayload( createLocalMessageActionType, messageInfo, ); }, ); await this.uploadFiles(localMessageID, selectionsWithIDs); }; async uploadFiles( localMessageID: string, selectionsWithIDs: $ReadOnlyArray, ) { const results = await Promise.all( selectionsWithIDs.map((selectionWithID) => this.uploadFile(localMessageID, selectionWithID), ), ); const errors = [...new Set(results.filter(Boolean))]; if (errors.length > 0) { displayActionResultModal(errors.join(', ') + ' :('); } } async uploadFile( localMessageID: string, selectionWithID: SelectionWithID, ): Promise { const { localID, selection } = selectionWithID; const start = selection.sendTime; let steps = [selection], serverID, userTime, errorMessage; let reportPromise; const finish = async (result: MediaMissionResult) => { if (reportPromise) { const finalSteps = await reportPromise; steps.push(...finalSteps); } const totalTime = Date.now() - start; userTime = userTime ? userTime : totalTime; this.queueMediaMissionReport( { localID, localMessageID, serverID }, { steps, result, totalTime, userTime }, ); return errorMessage; }; const fail = (message: string) => { errorMessage = message; this.handleUploadFailure(localMessageID, localID, message); userTime = Date.now() - start; }; let processedMedia; const processingStart = Date.now(); try { const processMediaReturn = processMedia( selection, this.mediaProcessConfig(), ); reportPromise = processMediaReturn.reportPromise; const processResult = await processMediaReturn.resultPromise; if (!processResult.success) { const message = processResult.reason === 'video_too_long' ? `can't do vids longer than ${videoDurationLimit}min` : 'processing failed'; fail(message); return await finish(processResult); } processedMedia = processResult; } catch (e) { fail('processing failed'); return await finish({ success: false, reason: 'processing_exception', time: Date.now() - processingStart, exceptionMessage: getMessageForException(e), }); } const { uploadURI, shouldDisposePath, filename, mime } = processedMedia; const { hasWiFi } = this.props; const uploadStart = Date.now(); let uploadExceptionMessage, uploadResult, mediaMissionResult; try { uploadResult = await this.props.uploadMultimedia( { uri: uploadURI, name: filename, type: mime }, { ...processedMedia.dimensions, loop: processedMedia.loop }, { onProgress: (percent: number) => this.setProgress(localMessageID, localID, percent), uploadBlob: this.uploadBlob, }, ); mediaMissionResult = { success: true }; } catch (e) { uploadExceptionMessage = getMessageForException(e); fail('upload failed'); mediaMissionResult = { success: false, reason: 'http_upload_failed', exceptionMessage: uploadExceptionMessage, }; } if (uploadResult) { const { id, mediaType, uri, dimensions, loop } = uploadResult; serverID = id; this.props.dispatchActionPayload(updateMultimediaMessageMediaActionType, { messageID: localMessageID, currentMediaID: localID, mediaUpdate: { id, type: mediaType, uri, dimensions, localMediaSelection: undefined, loop, }, }); userTime = Date.now() - start; } const processSteps = await reportPromise; reportPromise = null; steps.push(...processSteps); steps.push({ step: 'upload', success: !!uploadResult, exceptionMessage: uploadExceptionMessage, time: Date.now() - uploadStart, inputFilename: filename, outputMediaType: uploadResult && uploadResult.mediaType, outputURI: uploadResult && uploadResult.uri, outputDimensions: uploadResult && uploadResult.dimensions, outputLoop: uploadResult && uploadResult.loop, hasWiFi, }); const promises = []; if (shouldDisposePath) { // If processMedia needed to do any transcoding before upload, we dispose // of the resultant temporary file here. Since the transcoded temporary // file is only used for upload, we can dispose of it after processMedia // (reportPromise) and the upload are complete promises.push( (async () => { const disposeStep = await disposeTempFile(shouldDisposePath); steps.push(disposeStep); })(), ); } if (selection.captureTime) { // If we are uploading a newly captured photo, we dispose of the original // file here. Note that we try to save photo captures to the camera roll // if we have permission. Even if we fail, this temporary file isn't // visible to the user, so there's no point in keeping it around. Since // the initial URI is used in rendering paths, we have to wait for it to // be replaced with the remote URI before we can dispose const captureURI = selection.uri; promises.push( (async () => { const { steps: clearSteps, result: capturePath, } = await this.waitForCaptureURIUnload(captureURI); steps.push(...clearSteps); if (!capturePath) { return; } const disposeStep = await disposeTempFile(capturePath); steps.push(disposeStep); })(), ); } await Promise.all(promises); return await finish(mediaMissionResult); } mediaProcessConfig() { const { hasWiFi, viewerID } = this.props; if (__DEV__ || (viewerID && isStaff(viewerID))) { return { hasWiFi, finalFileHeaderCheck: true, }; } return { hasWiFi }; } setProgress( localMessageID: string, localUploadID: string, progressPercent: number, ) { this.setState((prevState) => { const pendingUploads = prevState.pendingUploads[localMessageID]; if (!pendingUploads) { return {}; } const pendingUpload = pendingUploads[localUploadID]; if (!pendingUpload) { return {}; } const newOutOfHundred = Math.floor(progressPercent * 100); const oldOutOfHundred = Math.floor(pendingUpload.progressPercent * 100); if (newOutOfHundred === oldOutOfHundred) { return {}; } const newPendingUploads = { ...pendingUploads, [localUploadID]: { ...pendingUpload, progressPercent, }, }; return { pendingUploads: { ...prevState.pendingUploads, [localMessageID]: newPendingUploads, }, }; }); } uploadBlob = async ( url: string, cookie: ?string, sessionID: ?string, input: { [key: string]: mixed }, options?: ?FetchJSONOptions, ): Promise => { invariant( cookie && input.multimedia && Array.isArray(input.multimedia) && input.multimedia.length === 1 && input.multimedia[0] && typeof input.multimedia[0] === 'object', 'InputStateContainer.uploadBlob sent incorrect input', ); const { uri, name, type } = input.multimedia[0]; invariant( typeof uri === 'string' && typeof name === 'string' && typeof type === 'string', 'InputStateContainer.uploadBlob sent incorrect input', ); const parameters = {}; parameters.cookie = cookie; parameters.filename = name; for (let key in input) { if ( key === 'multimedia' || key === 'cookie' || key === 'sessionID' || key === 'filename' ) { continue; } const value = input[key]; invariant( typeof value === 'string', 'blobUpload calls can only handle string values for non-multimedia keys', ); parameters[key] = value; } let path = uri; if (Platform.OS === 'android') { const resolvedPath = pathFromURI(uri); if (resolvedPath) { path = resolvedPath; } } const uploadID = await Upload.startUpload({ url, path, type: 'multipart', headers: { Accept: 'application/json', }, field: 'multimedia', parameters, }); if (options && options.abortHandler) { options.abortHandler(() => { Upload.cancelUpload(uploadID); }); } return await new Promise((resolve, reject) => { Upload.addListener('error', uploadID, (data) => { reject(data.error); }); Upload.addListener('cancelled', uploadID, () => { reject(new Error('request aborted')); }); Upload.addListener('completed', uploadID, (data) => { resolve(JSON.parse(data.responseBody)); }); if (options && options.onProgress) { const { onProgress } = options; Upload.addListener('progress', uploadID, (data) => onProgress(data.progress / 100), ); } }); }; handleUploadFailure( localMessageID: string, localUploadID: string, message: string, ) { this.setState((prevState) => { const uploads = prevState.pendingUploads[localMessageID]; const upload = uploads[localUploadID]; if (!upload) { // The upload has been completed before it failed return {}; } return { pendingUploads: { ...prevState.pendingUploads, [localMessageID]: { ...uploads, [localUploadID]: { ...upload, failed: message, progressPercent: 0, }, }, }, }; }); } queueMediaMissionReport( ids: {| localID: string, localMessageID: string, serverID: ?string |}, mediaMission: MediaMission, ) { const report: MediaMissionReportCreationRequest = { type: reportTypes.MEDIA_MISSION, time: Date.now(), platformDetails: getConfig().platformDetails, mediaMission, uploadServerID: ids.serverID, uploadLocalID: ids.localID, messageLocalID: ids.localMessageID, }; this.props.dispatchActionPayload(queueReportsActionType, { reports: [report], }); } messageHasUploadFailure = (localMessageID: string) => { const pendingUploads = this.state.pendingUploads[localMessageID]; if (!pendingUploads) { return false; } for (let localUploadID in pendingUploads) { const { failed } = pendingUploads[localUploadID]; if (failed) { return true; } } return false; }; addReply = (message: string) => { this.replyCallbacks.forEach((addReplyCallback) => addReplyCallback(message), ); }; addReplyListener = (callbackReply: (message: string) => void) => { this.replyCallbacks.push(callbackReply); }; removeReplyListener = (callbackReply: (message: string) => void) => { this.replyCallbacks = this.replyCallbacks.filter( (candidate) => candidate !== callbackReply, ); }; retryMultimediaMessage = async (localMessageID: string) => { const rawMessageInfo = this.props.messageStoreMessages[localMessageID]; invariant(rawMessageInfo, `rawMessageInfo ${localMessageID} should exist`); let pendingUploads = this.state.pendingUploads[localMessageID]; if (!pendingUploads) { pendingUploads = {}; } const now = Date.now(); const updateMedia = (media: $ReadOnlyArray): T[] => media.map((singleMedia) => { const oldID = singleMedia.id; if (!oldID.startsWith('localUpload')) { // already uploaded return singleMedia; } if (pendingUploads[oldID] && !pendingUploads[oldID].failed) { // still being uploaded return singleMedia; } // If we have an incomplete upload that isn't in pendingUploads, that // indicates the app has restarted. We'll reassign a new localID to // avoid collisions. Note that this isn't necessary for the message ID // since the localID reducer prevents collisions there const id = pendingUploads[oldID] ? oldID : getNewLocalID(); const oldSelection = singleMedia.localMediaSelection; invariant( oldSelection, 'localMediaSelection should be set on locally created Media', ); const retries = oldSelection.retries ? oldSelection.retries + 1 : 1; // We switch for Flow let selection; if (oldSelection.step === 'photo_capture') { selection = { ...oldSelection, sendTime: now, retries }; } else if (oldSelection.step === 'photo_library') { selection = { ...oldSelection, sendTime: now, retries }; } else { selection = { ...oldSelection, sendTime: now, retries }; } if (singleMedia.type === 'photo') { return { type: 'photo', ...singleMedia, id, localMediaSelection: selection, }; } else { return { type: 'video', ...singleMedia, id, localMediaSelection: selection, }; } }); let newRawMessageInfo; // This conditional is for Flow if (rawMessageInfo.type === messageTypes.MULTIMEDIA) { newRawMessageInfo = ({ ...rawMessageInfo, time: now, media: updateMedia(rawMessageInfo.media), }: RawMediaMessageInfo); } else if (rawMessageInfo.type === messageTypes.IMAGES) { newRawMessageInfo = ({ ...rawMessageInfo, time: now, media: updateMedia(rawMessageInfo.media), }: RawImagesMessageInfo); } else { invariant(false, `rawMessageInfo ${localMessageID} should be multimedia`); } const incompleteMedia: Media[] = []; for (let singleMedia of newRawMessageInfo.media) { if (singleMedia.id.startsWith('localUpload')) { incompleteMedia.push(singleMedia); } } if (incompleteMedia.length === 0) { this.dispatchMultimediaMessageAction(newRawMessageInfo); this.setState((prevState) => ({ pendingUploads: { ...prevState.pendingUploads, [localMessageID]: {}, }, })); return; } const retryMedia = incompleteMedia.filter( ({ id }) => !pendingUploads[id] || pendingUploads[id].failed, ); if (retryMedia.length === 0) { // All media are already in the process of being uploaded return; } // We're not actually starting the send here, // we just use this action to update the message in Redux this.props.dispatchActionPayload( sendMultimediaMessageActionTypes.started, newRawMessageInfo, ); // We clear out the failed status on individual media here, // which makes the UI show pending status instead of error messages for (let { id } of retryMedia) { pendingUploads[id] = { failed: null, progressPercent: 0, }; } this.setState((prevState) => ({ pendingUploads: { ...prevState.pendingUploads, [localMessageID]: pendingUploads, }, })); const selectionsWithIDs = retryMedia.map((singleMedia) => { const { id, localMediaSelection } = singleMedia; invariant( localMediaSelection, 'localMediaSelection should be set on locally created Media', ); return { selection: localMediaSelection, localID: id }; }); await this.uploadFiles(localMessageID, selectionsWithIDs); }; registerSendCallback = (callback: () => void) => { this.sendCallbacks.push(callback); }; unregisterSendCallback = (callback: () => void) => { this.sendCallbacks = this.sendCallbacks.filter( (candidate) => candidate !== callback, ); }; reportURIDisplayed = (uri: string, loaded: boolean) => { const prevActiveURI = this.activeURIs.get(uri); const curCount = prevActiveURI && prevActiveURI.count; const prevCount = curCount ? curCount : 0; const count = loaded ? prevCount + 1 : prevCount - 1; const prevOnClear = prevActiveURI && prevActiveURI.onClear; const onClear = prevOnClear ? prevOnClear : []; const activeURI = { count, onClear }; if (count) { this.activeURIs.set(uri, activeURI); return; } this.activeURIs.delete(uri); for (let callback of onClear) { callback(); } }; waitForCaptureURIUnload(uri: string) { const start = Date.now(); const path = pathFromURI(uri); if (!path) { return Promise.resolve({ result: null, steps: [ { step: 'wait_for_capture_uri_unload', success: false, time: Date.now() - start, uri, }, ], }); } const getResult = () => ({ result: path, steps: [ { step: 'wait_for_capture_uri_unload', success: true, time: Date.now() - start, uri, }, ], }); const activeURI = this.activeURIs.get(uri); if (!activeURI) { return Promise.resolve(getResult()); } return new Promise((resolve) => { const finish = () => resolve(getResult()); const newActiveURI = { ...activeURI, onClear: [...activeURI.onClear, finish], }; this.activeURIs.set(uri, newActiveURI); }); } render() { const inputState = this.inputStateSelector(this.state); return ( {this.props.children} ); } } const mediaCreationLoadingStatusSelector = createLoadingStatusSelector( sendMultimediaMessageActionTypes, ); const textCreationLoadingStatusSelector = createLoadingStatusSelector( sendTextMessageActionTypes, ); export default connect( (state: AppState) => ({ viewerID: state.currentUserInfo && state.currentUserInfo.id, nextLocalID: state.nextLocalID, messageStoreMessages: state.messageStore.messages, ongoingMessageCreation: combineLoadingStatuses( mediaCreationLoadingStatusSelector(state), textCreationLoadingStatusSelector(state), ) === 'loading', hasWiFi: state.connectivity.hasWiFi, }), { uploadMultimedia, sendMultimediaMessage, sendTextMessage }, )(InputStateContainer); diff --git a/native/input/input-state.js b/native/input/input-state.js index e416e4441..d4a8fcae4 100644 --- a/native/input/input-state.js +++ b/native/input/input-state.js @@ -1,73 +1,72 @@ // @flow import type { NativeMediaSelection } from 'lib/types/media-types'; import type { RawTextMessageInfo } from 'lib/types/message-types'; - -import * as React from 'react'; import PropTypes from 'prop-types'; +import * as React from 'react'; export type PendingMultimediaUpload = {| failed: ?string, progressPercent: number, |}; const pendingMultimediaUploadPropType = PropTypes.shape({ failed: PropTypes.string, progressPercent: PropTypes.number.isRequired, }); export type MessagePendingUploads = { [localUploadID: string]: PendingMultimediaUpload, }; const messagePendingUploadsPropType = PropTypes.objectOf( pendingMultimediaUploadPropType, ); export type PendingMultimediaUploads = { [localMessageID: string]: MessagePendingUploads, }; const pendingMultimediaUploadsPropType = PropTypes.objectOf( messagePendingUploadsPropType, ); export type InputState = {| pendingUploads: PendingMultimediaUploads, sendTextMessage: (messageInfo: RawTextMessageInfo) => void, sendMultimediaMessage: ( threadID: string, selections: $ReadOnlyArray, ) => Promise, addReply: (text: string) => void, addReplyListener: ((message: string) => void) => void, removeReplyListener: ((message: string) => void) => void, messageHasUploadFailure: (localMessageID: string) => boolean, retryMultimediaMessage: (localMessageID: string) => Promise, registerSendCallback: (() => void) => void, unregisterSendCallback: (() => void) => void, uploadInProgress: () => boolean, reportURIDisplayed: (uri: string, loaded: boolean) => void, |}; const inputStatePropType = PropTypes.shape({ pendingUploads: pendingMultimediaUploadsPropType.isRequired, sendTextMessage: PropTypes.func.isRequired, sendMultimediaMessage: PropTypes.func.isRequired, addReply: PropTypes.func.isRequired, addReplyListener: PropTypes.func.isRequired, removeReplyListener: PropTypes.func.isRequired, messageHasUploadFailure: PropTypes.func.isRequired, retryMultimediaMessage: PropTypes.func.isRequired, uploadInProgress: PropTypes.func.isRequired, reportURIDisplayed: PropTypes.func.isRequired, }); const InputStateContext = React.createContext(null); export { messagePendingUploadsPropType, pendingMultimediaUploadPropType, inputStatePropType, InputStateContext, }; diff --git a/native/keyboard/keyboard-input-host.react.js b/native/keyboard/keyboard-input-host.react.js index 47d4f5642..a20e5e03e 100644 --- a/native/keyboard/keyboard-input-host.react.js +++ b/native/keyboard/keyboard-input-host.react.js @@ -1,131 +1,131 @@ // @flow +import invariant from 'invariant'; +import type { MediaLibrarySelection } from 'lib/types/media-types'; +import PropTypes from 'prop-types'; +import * as React from 'react'; +import { TextInput } from 'react-native'; +import { KeyboardAccessoryView } from 'react-native-keyboard-input'; + import { type InputState, inputStatePropType, InputStateContext, } from '../input/input-state'; +import { mediaGalleryKeyboardName } from '../media/media-gallery-keyboard.react'; +import { activeMessageListSelector } from '../navigation/nav-selectors'; +import { NavContext } from '../navigation/navigation-context'; +import { useStyles } from '../themes/colors'; + import { type KeyboardState, keyboardStatePropType, KeyboardContext, -} from '../keyboard/keyboard-state'; -import type { MediaLibrarySelection } from 'lib/types/media-types'; -import { NavContext } from '../navigation/navigation-context'; - -import * as React from 'react'; -import PropTypes from 'prop-types'; -import { TextInput } from 'react-native'; -import { KeyboardAccessoryView } from 'react-native-keyboard-input'; -import invariant from 'invariant'; - -import { useStyles } from '../themes/colors'; -import { mediaGalleryKeyboardName } from '../media/media-gallery-keyboard.react'; -import { activeMessageListSelector } from '../navigation/nav-selectors'; +} from './keyboard-state'; type BaseProps = {| +textInputRef?: React.ElementRef, |}; type Props = {| ...BaseProps, // Redux state +styles: typeof unboundStyles, +activeMessageList: ?string, // withKeyboardState +keyboardState: ?KeyboardState, // withInputState +inputState: ?InputState, |}; class KeyboardInputHost extends React.PureComponent { static propTypes = { textInputRef: PropTypes.object, styles: PropTypes.objectOf(PropTypes.object).isRequired, activeMessageList: PropTypes.string, keyboardState: keyboardStatePropType, inputState: inputStatePropType, }; componentDidUpdate(prevProps: Props) { if ( prevProps.activeMessageList && this.props.activeMessageList !== prevProps.activeMessageList ) { this.hideMediaGallery(); } } static mediaGalleryOpen(props: Props) { const { keyboardState } = props; return !!(keyboardState && keyboardState.mediaGalleryOpen); } render() { const kbComponent = KeyboardInputHost.mediaGalleryOpen(this.props) ? mediaGalleryKeyboardName : null; return ( ); } onMediaGalleryItemSelected = ( keyboardName: string, selections: $ReadOnlyArray, ) => { const { keyboardState } = this.props; invariant( keyboardState, 'keyboardState should be set in onMediaGalleryItemSelected', ); keyboardState.dismissKeyboard(); const mediaGalleryThreadID = keyboardState.getMediaGalleryThreadID(); if (mediaGalleryThreadID === null || mediaGalleryThreadID === undefined) { return; } const { inputState } = this.props; invariant( inputState, 'inputState should be set in onMediaGalleryItemSelected', ); inputState.sendMultimediaMessage(mediaGalleryThreadID, selections); }; hideMediaGallery = () => { const { keyboardState } = this.props; invariant(keyboardState, 'keyboardState should be initialized'); keyboardState.hideMediaGallery(); }; } const unboundStyles = { kbInitialProps: { backgroundColor: 'listBackground', }, }; export default React.memo(function ConnectedKeyboardInputHost( props: BaseProps, ) { const inputState = React.useContext(InputStateContext); const keyboardState = React.useContext(KeyboardContext); const navContext = React.useContext(NavContext); const styles = useStyles(unboundStyles); const activeMessageList = activeMessageListSelector(navContext); return ( ); }); diff --git a/native/keyboard/keyboard-state-container.react.js b/native/keyboard/keyboard-state-container.react.js index a0ee7195f..1431d6256 100644 --- a/native/keyboard/keyboard-state-container.react.js +++ b/native/keyboard/keyboard-state-container.react.js @@ -1,157 +1,157 @@ // @flow -import * as React from 'react'; +import sleep from 'lib/utils/sleep'; import PropTypes from 'prop-types'; -import { KeyboardUtils } from 'react-native-keyboard-input'; +import * as React from 'react'; import { Platform } from 'react-native'; +import { KeyboardUtils } from 'react-native-keyboard-input'; -import sleep from 'lib/utils/sleep'; +import { tabBarAnimationDuration } from '../navigation/tab-bar.react'; +import { waitForInteractions } from '../utils/timers'; import { addKeyboardShowListener, addKeyboardDismissListener, removeKeyboardListener, androidKeyboardResizesFrame, } from './keyboard'; -import { KeyboardContext } from './keyboard-state'; import KeyboardInputHost from './keyboard-input-host.react'; -import { waitForInteractions } from '../utils/timers'; -import { tabBarAnimationDuration } from '../navigation/tab-bar.react'; +import { KeyboardContext } from './keyboard-state'; type Props = {| children: React.Node, |}; type State = {| systemKeyboardShowing: boolean, mediaGalleryOpen: boolean, mediaGalleryThreadID: ?string, renderKeyboardInputHost: boolean, |}; class KeyboardStateContainer extends React.PureComponent { static propTypes = { children: PropTypes.node.isRequired, }; state: State = { systemKeyboardShowing: false, mediaGalleryOpen: false, mediaGalleryThreadID: null, renderKeyboardInputHost: false, }; keyboardShowListener: ?Object; keyboardDismissListener: ?Object; keyboardShow = () => { this.setState({ systemKeyboardShowing: true }); }; keyboardDismiss = () => { this.setState({ systemKeyboardShowing: false }); }; componentDidMount() { this.keyboardShowListener = addKeyboardShowListener(this.keyboardShow); this.keyboardDismissListener = addKeyboardDismissListener( this.keyboardDismiss, ); } componentWillUnmount() { if (this.keyboardShowListener) { removeKeyboardListener(this.keyboardShowListener); this.keyboardShowListener = null; } if (this.keyboardDismissListener) { removeKeyboardListener(this.keyboardDismissListener); this.keyboardDismissListener = null; } } componentDidUpdate(prevProps: Props, prevState: State) { if (Platform.OS !== 'android' || androidKeyboardResizesFrame) { return; } if (this.state.mediaGalleryOpen && !prevState.mediaGalleryOpen) { (async () => { await sleep(tabBarAnimationDuration); await waitForInteractions(); this.setState({ renderKeyboardInputHost: true }); })(); } } dismissKeyboard = () => { KeyboardUtils.dismiss(); this.hideMediaGallery(); }; dismissKeyboardIfShowing = () => { if (!this.keyboardShowing) { return false; } this.dismissKeyboard(); return true; }; get keyboardShowing() { const { systemKeyboardShowing, mediaGalleryOpen } = this.state; return systemKeyboardShowing || mediaGalleryOpen; } showMediaGallery = (threadID: string) => { const updates: $Shape = { mediaGalleryOpen: true, mediaGalleryThreadID: threadID, }; if (androidKeyboardResizesFrame) { updates.renderKeyboardInputHost = true; } this.setState(updates); }; hideMediaGallery = () => { this.setState({ mediaGalleryOpen: false, mediaGalleryThreadID: null, renderKeyboardInputHost: false, }); }; getMediaGalleryThreadID = () => this.state.mediaGalleryThreadID; render() { const { systemKeyboardShowing, mediaGalleryOpen, renderKeyboardInputHost, } = this.state; const { keyboardShowing, dismissKeyboard, dismissKeyboardIfShowing, showMediaGallery, hideMediaGallery, getMediaGalleryThreadID, } = this; const keyboardState = { keyboardShowing, dismissKeyboard, dismissKeyboardIfShowing, systemKeyboardShowing, mediaGalleryOpen, showMediaGallery, hideMediaGallery, getMediaGalleryThreadID, }; const keyboardInputHost = renderKeyboardInputHost ? ( ) : null; return ( {this.props.children} {keyboardInputHost} ); } } export default KeyboardStateContainer; diff --git a/native/keyboard/keyboard-state.js b/native/keyboard/keyboard-state.js index 2e6210136..ea87d9d0e 100644 --- a/native/keyboard/keyboard-state.js +++ b/native/keyboard/keyboard-state.js @@ -1,30 +1,30 @@ // @flow -import * as React from 'react'; import PropTypes from 'prop-types'; +import * as React from 'react'; export type KeyboardState = {| keyboardShowing: boolean, dismissKeyboard: () => void, dismissKeyboardIfShowing: () => boolean, systemKeyboardShowing: boolean, mediaGalleryOpen: boolean, showMediaGallery: (threadID: string) => void, hideMediaGallery: () => void, getMediaGalleryThreadID: () => ?string, |}; const keyboardStatePropType = PropTypes.shape({ keyboardShowing: PropTypes.bool.isRequired, dismissKeyboard: PropTypes.func.isRequired, dismissKeyboardIfShowing: PropTypes.func.isRequired, systemKeyboardShowing: PropTypes.bool.isRequired, mediaGalleryOpen: PropTypes.bool.isRequired, showMediaGallery: PropTypes.func.isRequired, hideMediaGallery: PropTypes.func.isRequired, getMediaGalleryThreadID: PropTypes.func.isRequired, }); const KeyboardContext = React.createContext(null); export { keyboardStatePropType, KeyboardContext }; diff --git a/native/lifecycle/lifecycle-event-emitter.js b/native/lifecycle/lifecycle-event-emitter.js index b77e6d6f5..2c2e8b069 100644 --- a/native/lifecycle/lifecycle-event-emitter.js +++ b/native/lifecycle/lifecycle-event-emitter.js @@ -1,53 +1,52 @@ // @flow import invariant from 'invariant'; import { Platform } from 'react-native'; - +import NativeEventEmitter from 'react-native/Libraries/EventEmitter/NativeEventEmitter'; import type { TurboModule } from 'react-native/Libraries/TurboModule/RCTExport'; import * as TurboModuleRegistry from 'react-native/Libraries/TurboModule/TurboModuleRegistry'; // eslint-disable-next-line import/default -import NativeEventEmitter from 'react-native/Libraries/EventEmitter/NativeEventEmitter'; interface Spec extends TurboModule { +getConstants: () => {| initialStatus: string, |}; +addListener: (eventName: string) => void; +removeListeners: (count: number) => void; } const AndroidLifecycle = (TurboModuleRegistry.getEnforcing( 'AndroidLifecycle', ): Spec); class LifecycleEventEmitter extends NativeEventEmitter { currentLifecycleStatus: ?string; constructor() { super(AndroidLifecycle); this.currentLifecycleStatus = AndroidLifecycle.getConstants().initialStatus; this.addLifecycleListener((state) => { this.currentAndroidLifecycle = state; }); } addLifecycleListener = (listener: (state: ?string) => mixed) => { return this.addListener('LIFECYCLE_CHANGE', (event) => { listener(event.status); }); }; } let lifecycleEventEmitter; function getLifecycleEventEmitter() { if (lifecycleEventEmitter) { return lifecycleEventEmitter; } invariant( Platform.OS === 'android', 'LifecycleEventEmitter only works on Android', ); lifecycleEventEmitter = new LifecycleEventEmitter(); return lifecycleEventEmitter; } export { getLifecycleEventEmitter }; diff --git a/native/lifecycle/lifecycle-handler.react.js b/native/lifecycle/lifecycle-handler.react.js index 819a0f731..41bc1d701 100644 --- a/native/lifecycle/lifecycle-handler.react.js +++ b/native/lifecycle/lifecycle-handler.react.js @@ -1,44 +1,44 @@ // @flow -import * as React from 'react'; -import { useDispatch } from 'react-redux'; - import { backgroundActionType, foregroundActionType, } from 'lib/reducers/foreground-reducer'; +import * as React from 'react'; +import { useDispatch } from 'react-redux'; import { appBecameInactive } from '../redux/redux-setup'; + import { addLifecycleListener } from './lifecycle'; const LifecycleHandler = React.memo<{||}>(() => { const dispatch = useDispatch(); const lastStateRef = React.useRef(); const onLifecycleChange = React.useCallback( (nextState: ?string) => { if (!nextState || nextState === 'unknown') { return; } const lastState = lastStateRef.current; lastStateRef.current = nextState; if (lastState === 'background' && nextState === 'active') { dispatch({ type: foregroundActionType, payload: null }); } else if (lastState !== 'background' && nextState === 'background') { dispatch({ type: backgroundActionType, payload: null }); appBecameInactive(); } }, [lastStateRef, dispatch], ); React.useEffect(() => { const subscription = addLifecycleListener(onLifecycleChange); return () => subscription.remove(); }, [onLifecycleChange]); return null; }); LifecycleHandler.displayName = 'LifecycleHandler'; export default LifecycleHandler; diff --git a/native/markdown/markdown-link.react.js b/native/markdown/markdown-link.react.js index 08eae4340..536caf484 100644 --- a/native/markdown/markdown-link.react.js +++ b/native/markdown/markdown-link.react.js @@ -1,53 +1,52 @@ // @flow +import { normalizeURL } from 'lib/utils/url-utils'; import * as React from 'react'; import { Text, Linking, Alert } from 'react-native'; -import { normalizeURL } from 'lib/utils/url-utils'; - import { MarkdownLinkContext } from './markdown-link-context'; function useDisplayLinkPrompt(inputURL: string) { const markdownLinkContext = React.useContext(MarkdownLinkContext); const setLinkPressActive = markdownLinkContext?.setLinkPressActive; const onDismiss = React.useCallback(() => { setLinkPressActive?.(false); }, [setLinkPressActive]); const url = normalizeURL(inputURL); const onConfirm = React.useCallback(() => { onDismiss(); Linking.openURL(url); }, [url, onDismiss]); let displayURL = url.substring(0, 64); if (url.length > displayURL.length) { displayURL += '…'; } return React.useCallback(() => { setLinkPressActive && setLinkPressActive(true); Alert.alert( 'External link', `You sure you want to open this link?\n\n${displayURL}`, [ { text: 'Cancel', style: 'cancel', onPress: onDismiss }, { text: 'Open', onPress: onConfirm }, ], { cancelable: true, onDismiss }, ); }, [setLinkPressActive, displayURL, onConfirm, onDismiss]); } type TextProps = React.ElementConfig; type Props = {| +target: string, +children: React.Node, ...TextProps, |}; function MarkdownLink(props: Props) { const { target, ...rest } = props; const onPressLink = useDisplayLinkPrompt(target); return ; } export default MarkdownLink; diff --git a/native/markdown/markdown.react.js b/native/markdown/markdown.react.js index cabbc55fe..d00ab9ebb 100644 --- a/native/markdown/markdown.react.js +++ b/native/markdown/markdown.react.js @@ -1,76 +1,76 @@ // @flow -import type { TextStyle } from '../types/styles'; -import type { TextStyle as FlattenedTextStyle } from 'react-native/Libraries/StyleSheet/StyleSheet'; -import type { MarkdownRules } from './rules.react'; - +import invariant from 'invariant'; +import { onlyEmojiRegex } from 'lib/shared/emojis'; import * as React from 'react'; -import * as SimpleMarkdown from 'simple-markdown'; import { View, Text, StyleSheet } from 'react-native'; -import invariant from 'invariant'; +import type { TextStyle as FlattenedTextStyle } from 'react-native/Libraries/StyleSheet/StyleSheet'; +import * as SimpleMarkdown from 'simple-markdown'; -import { onlyEmojiRegex } from 'lib/shared/emojis'; +import type { TextStyle } from '../types/styles'; + +import type { MarkdownRules } from './rules.react'; type Props = {| +style: TextStyle, +children: string, +rules: MarkdownRules, |}; function Markdown(props: Props) { const { style, children, rules } = props; const { simpleMarkdownRules, emojiOnlyFactor, container } = rules; const parser = React.useMemo( () => SimpleMarkdown.parserFor(simpleMarkdownRules), [simpleMarkdownRules], ); const ast = React.useMemo( () => parser(children, { disableAutoBlockNewlines: true, container }), [parser, children, container], ); const output = React.useMemo( () => SimpleMarkdown.outputFor(simpleMarkdownRules, 'react'), [simpleMarkdownRules], ); const emojiOnly = React.useMemo(() => { if (emojiOnlyFactor === null || emojiOnlyFactor === undefined) { return false; } return onlyEmojiRegex.test(children); }, [emojiOnlyFactor, children]); const textStyle = React.useMemo(() => { if ( !emojiOnly || emojiOnlyFactor === null || emojiOnlyFactor === undefined ) { return style; } const flattened: FlattenedTextStyle = (StyleSheet.flatten(style): any); invariant( flattened && typeof flattened === 'object', `Markdown component should have style`, ); const { fontSize } = flattened; invariant( fontSize, `style prop should have fontSize if using emojiOnlyFactor`, ); return { ...flattened, fontSize: fontSize * emojiOnlyFactor }; }, [emojiOnly, style, emojiOnlyFactor]); const renderedOutput = React.useMemo( () => output(ast, { textStyle, container }), [ast, output, textStyle, container], ); if (container === 'Text') { return {renderedOutput}; } else { return {renderedOutput}; } } export default Markdown; diff --git a/native/markdown/rules.react.js b/native/markdown/rules.react.js index 4e59152ed..643cd56f3 100644 --- a/native/markdown/rules.react.js +++ b/native/markdown/rules.react.js @@ -1,375 +1,374 @@ // @flow +import { relativeMemberInfoSelectorForMembersOfThread } from 'lib/selectors/user-selectors'; +import * as SharedMarkdown from 'lib/shared/markdown'; import type { RelativeMemberInfo } from 'lib/types/thread-types'; - +import _memoize from 'lodash/memoize'; import * as React from 'react'; import { Text, View } from 'react-native'; import * as SimpleMarkdown from 'simple-markdown'; -import _memoize from 'lodash/memoize'; -import * as SharedMarkdown from 'lib/shared/markdown'; -import { relativeMemberInfoSelectorForMembersOfThread } from 'lib/selectors/user-selectors'; +import { useSelector } from '../redux/redux-utils'; -import { getMarkdownStyles } from './styles'; import MarkdownLink from './markdown-link.react'; -import { useSelector } from '../redux/redux-utils'; +import { getMarkdownStyles } from './styles'; export type MarkdownRules = {| +simpleMarkdownRules: SimpleMarkdown.ParserRules, +emojiOnlyFactor: ?number, // We need to use a Text container for Entry because it needs to match up // exactly with TextInput. However, if we use a Text container, we can't // support styles for things like blockQuote, which rely on rendering as a // View, and Views can't be nested inside Texts without explicit height and // width +container: 'View' | 'Text', |}; // Entry requires a seamless transition between Markdown and TextInput // components, so we can't do anything that would change the position of text const inlineMarkdownRules: (boolean) => MarkdownRules = _memoize( (useDarkStyle) => { const styles = getMarkdownStyles(useDarkStyle ? 'dark' : 'light'); const simpleMarkdownRules = { // Matches 'https://google.com' during parse phase and returns a 'link' node url: { ...SimpleMarkdown.defaultRules.url, // simple-markdown is case-sensitive, but we don't want to be match: SimpleMarkdown.inlineRegex(SharedMarkdown.urlRegex), }, // Matches '[Google](https://google.com)' during parse phase and handles // rendering all 'link' nodes, including for 'autolink' and 'url' link: { ...SimpleMarkdown.defaultRules.link, match: () => null, react( node: SimpleMarkdown.SingleASTNode, output: SimpleMarkdown.Output, state: SimpleMarkdown.State, ) { return ( {output(node.content, state)} ); }, }, // Each line gets parsed into a 'paragraph' node. The AST returned by the // parser will be an array of one or more 'paragraph' nodes paragraph: { ...SimpleMarkdown.defaultRules.paragraph, // simple-markdown's default RegEx collapses multiple newlines into one. // We want to keep the newlines, but when rendering within a View, we // strip just one trailing newline off, since the View adds vertical // spacing between its children match: (source: string, state: SimpleMarkdown.State) => { if (state.inline) { return null; } else if (state.container === 'View') { return SharedMarkdown.paragraphStripTrailingNewlineRegex.exec( source, ); } else { return SharedMarkdown.paragraphRegex.exec(source); } }, parse( capture: SimpleMarkdown.Capture, parse: SimpleMarkdown.Parser, state: SimpleMarkdown.State, ) { let content = capture[1]; if (state.container === 'View') { // React Native renders empty lines with less height. We want to // preserve the newline characters, so we replace empty lines with a // single space content = content.replace(/^$/m, ' '); } return { content: SimpleMarkdown.parseInline(parse, content, state), }; }, // eslint-disable-next-line react/display-name react: ( node: SimpleMarkdown.SingleASTNode, output: SimpleMarkdown.Output, state: SimpleMarkdown.State, ) => ( {output(node.content, state)} ), }, // This is the leaf node in the AST returned by the parse phase text: SimpleMarkdown.defaultRules.text, }; return { simpleMarkdownRules, emojiOnlyFactor: null, container: 'Text', }; }, ); // We allow the most markdown features for TextMessage, which doesn't have the // same requirements as Entry const fullMarkdownRules: (boolean) => MarkdownRules = _memoize( (useDarkStyle) => { const styles = getMarkdownStyles(useDarkStyle ? 'dark' : 'light'); const inlineRules = inlineMarkdownRules(useDarkStyle); const simpleMarkdownRules = { ...inlineRules.simpleMarkdownRules, // Matches '' during parse phase and returns a 'link' // node autolink: SimpleMarkdown.defaultRules.autolink, // Matches '[Google](https://google.com)' during parse phase and handles // rendering all 'link' nodes, including for 'autolink' and 'url' link: { ...inlineRules.simpleMarkdownRules.link, match: SimpleMarkdown.defaultRules.link.match, }, mailto: SimpleMarkdown.defaultRules.mailto, em: { ...SimpleMarkdown.defaultRules.em, // eslint-disable-next-line react/display-name react: ( node: SimpleMarkdown.SingleASTNode, output: SimpleMarkdown.Output, state: SimpleMarkdown.State, ) => ( {output(node.content, state)} ), }, strong: { ...SimpleMarkdown.defaultRules.strong, // eslint-disable-next-line react/display-name react: ( node: SimpleMarkdown.SingleASTNode, output: SimpleMarkdown.Output, state: SimpleMarkdown.State, ) => ( {output(node.content, state)} ), }, u: { ...SimpleMarkdown.defaultRules.u, // eslint-disable-next-line react/display-name react: ( node: SimpleMarkdown.SingleASTNode, output: SimpleMarkdown.Output, state: SimpleMarkdown.State, ) => ( {output(node.content, state)} ), }, del: { ...SimpleMarkdown.defaultRules.del, // eslint-disable-next-line react/display-name react: ( node: SimpleMarkdown.SingleASTNode, output: SimpleMarkdown.Output, state: SimpleMarkdown.State, ) => ( {output(node.content, state)} ), }, inlineCode: { ...SimpleMarkdown.defaultRules.inlineCode, // eslint-disable-next-line react/display-name react: ( node: SimpleMarkdown.SingleASTNode, output: SimpleMarkdown.Output, state: SimpleMarkdown.State, ) => ( {node.content} ), }, heading: { ...SimpleMarkdown.defaultRules.heading, match: SimpleMarkdown.blockRegex( SharedMarkdown.headingStripFollowingNewlineRegex, ), // eslint-disable-next-line react/display-name react( node: SimpleMarkdown.SingleASTNode, output: SimpleMarkdown.Output, state: SimpleMarkdown.State, ) { const headingStyle = styles['h' + node.level]; return ( {output(node.content, state)} ); }, }, blockQuote: { ...SimpleMarkdown.defaultRules.blockQuote, // match end of blockQuote by either \n\n or end of string match: SimpleMarkdown.blockRegex( SharedMarkdown.blockQuoteStripFollowingNewlineRegex, ), parse( capture: SimpleMarkdown.Capture, parse: SimpleMarkdown.Parser, state: SimpleMarkdown.State, ) { const content = capture[1].replace(/^ *> ?/gm, ''); return { content: parse(content, state), }; }, // eslint-disable-next-line react/display-name react: ( node: SimpleMarkdown.SingleASTNode, output: SimpleMarkdown.Output, state: SimpleMarkdown.State, ) => ( {output(node.content, state)} ), }, codeBlock: { ...SimpleMarkdown.defaultRules.codeBlock, match: SimpleMarkdown.blockRegex( SharedMarkdown.codeBlockStripTrailingNewlineRegex, ), parse(capture: SimpleMarkdown.Capture) { return { content: capture[1].replace(/^ {4}/gm, ''), }; }, // eslint-disable-next-line react/display-name react: ( node: SimpleMarkdown.SingleASTNode, output: SimpleMarkdown.Output, state: SimpleMarkdown.State, ) => ( {node.content} ), }, fence: { ...SimpleMarkdown.defaultRules.fence, match: SimpleMarkdown.blockRegex( SharedMarkdown.fenceStripTrailingNewlineRegex, ), parse: (capture: SimpleMarkdown.Capture) => ({ type: 'codeBlock', content: capture[2], }), }, json: { order: SimpleMarkdown.defaultRules.paragraph.order - 1, match: (source: string, state: SimpleMarkdown.State) => { if (state.inline) { return null; } return SharedMarkdown.jsonMatch(source); }, parse: (capture: SimpleMarkdown.Capture) => ({ type: 'codeBlock', content: SharedMarkdown.jsonPrint(capture), }), }, list: { ...SimpleMarkdown.defaultRules.list, match: SharedMarkdown.matchList, parse: SharedMarkdown.parseList, react( node: SimpleMarkdown.SingleASTNode, output: SimpleMarkdown.Output, state: SimpleMarkdown.State, ) { const children = node.items.map((item, i) => { const content = output(item, state); const bulletValue = node.ordered ? node.start + i + '. ' : '\u2022 '; return ( {bulletValue} {content} ); }); return {children}; }, }, escape: SimpleMarkdown.defaultRules.escape, }; return { ...inlineRules, simpleMarkdownRules, emojiOnlyFactor: 2, container: 'View', }; }, ); function useTextMessageRulesFunc(threadID: string) { const threadMembers = useSelector( relativeMemberInfoSelectorForMembersOfThread(threadID), ); return React.useMemo( () => _memoize<[boolean], MarkdownRules>((useDarkStyle: boolean) => textMessageRules(threadMembers, useDarkStyle), ), [threadMembers], ); } function textMessageRules( members: $ReadOnlyArray, useDarkStyle: boolean, ) { const styles = getMarkdownStyles(useDarkStyle ? 'dark' : 'light'); const baseRules = fullMarkdownRules(useDarkStyle); return { ...baseRules, simpleMarkdownRules: { ...baseRules.simpleMarkdownRules, mention: { ...SimpleMarkdown.defaultRules.strong, match: SharedMarkdown.matchMentions(members), parse: (capture: SimpleMarkdown.Capture) => ({ content: capture[0], }), // eslint-disable-next-line react/display-name react: ( node: SimpleMarkdown.SingleASTNode, output: SimpleMarkdown.Output, state: SimpleMarkdown.State, ) => ( {node.content} ), }, }, }; } export { inlineMarkdownRules, useTextMessageRulesFunc }; diff --git a/native/markdown/styles.js b/native/markdown/styles.js index 7ae81bab4..24a9fdacf 100644 --- a/native/markdown/styles.js +++ b/native/markdown/styles.js @@ -1,106 +1,105 @@ // @flow -import type { GlobalTheme } from '../types/themes'; - -import { Platform } from 'react-native'; import _memoize from 'lodash/memoize'; +import { Platform } from 'react-native'; import { getStylesForTheme } from '../themes/colors'; +import type { GlobalTheme } from '../types/themes'; const unboundStyles = { link: { color: 'link', textDecorationLine: 'underline', }, italics: { fontStyle: 'italic', }, bold: { fontWeight: 'bold', }, underline: { textDecorationLine: 'underline', }, strikethrough: { textDecorationLine: 'line-through', textDecorationStyle: 'solid', }, inlineCode: { backgroundColor: 'codeBackground', fontFamily: Platform.select({ ios: 'Menlo', default: 'monospace', }), fontSize: Platform.select({ ios: 17, default: 18, }), }, h1: { fontSize: 32, fontWeight: 'bold', }, h2: { fontSize: 24, fontWeight: 'bold', }, h3: { fontSize: 18, fontWeight: 'bold', }, h4: { fontSize: 16, fontWeight: 'bold', }, h5: { fontSize: 13, fontWeight: 'bold', }, h6: { fontSize: 11, fontWeight: 'bold', }, blockQuote: { backgroundColor: 'blockQuoteBackground', borderLeftColor: 'blockQuoteBorder', borderLeftWidth: 5, padding: 10, marginBottom: 6, marginVertical: 6, }, codeBlock: { backgroundColor: 'codeBackground', padding: 10, borderRadius: 5, marginVertical: 6, }, codeBlockText: { fontFamily: Platform.select({ ios: 'Menlo', default: 'monospace', }), fontSize: Platform.select({ ios: 17, default: 18, }), }, listBulletStyle: { fontWeight: 'bold', }, listRow: { flexDirection: 'row', }, insideListView: { flexShrink: 1, }, }; export type MarkdownStyles = typeof unboundStyles; const getMarkdownStyles: (GlobalTheme) => MarkdownStyles = _memoize( (theme: GlobalTheme) => { return getStylesForTheme(unboundStyles, theme); }, ); export { getMarkdownStyles }; diff --git a/native/media/blob-utils.js b/native/media/blob-utils.js index e105ed637..3c1eb621d 100644 --- a/native/media/blob-utils.js +++ b/native/media/blob-utils.js @@ -1,152 +1,150 @@ // @flow -import type { - MediaMissionStep, - MediaMissionFailure, -} from 'lib/types/media-types'; - import base64 from 'base-64'; import invariant from 'invariant'; - import { fileInfoFromData, bytesNeededForFileTypeCheck, } from 'lib/media/file-utils'; +import type { + MediaMissionStep, + MediaMissionFailure, +} from 'lib/types/media-types'; import { getMessageForException } from 'lib/utils/errors'; import { getFetchableURI } from './identifier-utils'; function blobToDataURI(blob: Blob): Promise { const fileReader = new FileReader(); return new Promise((resolve, reject) => { fileReader.onerror = (error) => { fileReader.abort(); reject(error); }; fileReader.onload = () => { invariant( typeof fileReader.result === 'string', 'FileReader.readAsDataURL should result in string', ); resolve(fileReader.result); }; fileReader.readAsDataURL(blob); }); } const base64CharsNeeded = 4 * Math.ceil(bytesNeededForFileTypeCheck / 3); function dataURIToIntArray(dataURI: string): Uint8Array { const uri = dataURI.replace(/\r?\n/g, ''); const firstComma = uri.indexOf(','); if (firstComma <= 4) { throw new TypeError('malformed data-URI'); } const meta = uri.substring(5, firstComma).split(';'); const base64Encoded = meta.some((metum) => metum === 'base64'); let data = unescape(uri.substr(firstComma + 1, base64CharsNeeded)); if (base64Encoded) { data = base64.decode(data); } return stringToIntArray(data); } function stringToIntArray(str: string): Uint8Array { const array = new Uint8Array(str.length); for (let i = 0; i < str.length; i++) { array[i] = str.charCodeAt(i); } return array; } type FetchBlobResult = {| success: true, base64: string, mime: string, |}; async function fetchBlob( inputURI: string, ): Promise<{| steps: $ReadOnlyArray, result: MediaMissionFailure | FetchBlobResult, |}> { const uri = getFetchableURI(inputURI); const steps = []; let blob, fetchExceptionMessage; const fetchStart = Date.now(); try { const response = await fetch(uri); blob = await response.blob(); } catch (e) { fetchExceptionMessage = getMessageForException(e); } steps.push({ step: 'fetch_blob', success: !!blob, exceptionMessage: fetchExceptionMessage, time: Date.now() - fetchStart, inputURI, uri, size: blob && blob.size, mime: blob && blob.type, }); if (!blob) { return { result: { success: false, reason: 'fetch_failed' }, steps }; } let dataURI, dataURIExceptionMessage; const dataURIStart = Date.now(); try { dataURI = await blobToDataURI(blob); } catch (e) { dataURIExceptionMessage = getMessageForException(e); } steps.push({ step: 'data_uri_from_blob', success: !!dataURI, exceptionMessage: dataURIExceptionMessage, time: Date.now() - dataURIStart, first255Chars: dataURI && dataURI.substring(0, 255), }); if (!dataURI) { return { result: { success: false, reason: 'data_uri_failed' }, steps }; } const firstComma = dataURI.indexOf(','); invariant(firstComma > 4, 'malformed data-URI'); const base64String = dataURI.substring(firstComma + 1); let mime = blob.type; if (!mime) { let mimeCheckExceptionMessage; const mimeCheckStart = Date.now(); try { const intArray = dataURIToIntArray(dataURI); ({ mime } = fileInfoFromData(intArray)); } catch (e) { mimeCheckExceptionMessage = getMessageForException(e); } steps.push({ step: 'mime_check', success: !!mime, exceptionMessage: mimeCheckExceptionMessage, time: Date.now() - mimeCheckStart, mime, }); } if (!mime) { return { result: { success: false, reason: 'mime_check_failed', mime }, steps, }; } return { result: { success: true, base64: base64String, mime }, steps }; } export { stringToIntArray, fetchBlob }; diff --git a/native/media/camera-modal.react.js b/native/media/camera-modal.react.js index 9a1bcb8f2..cf3605d7a 100644 --- a/native/media/camera-modal.react.js +++ b/native/media/camera-modal.react.js @@ -1,1233 +1,1232 @@ // @flow +import invariant from 'invariant'; +import { pathFromURI, filenameFromPathOrURI } from 'lib/media/file-utils'; import type { PhotoCapture } from 'lib/types/media-types'; -import { - type DimensionsInfo, - dimensionsInfoPropType, -} from '../redux/dimensions-updater.react'; -import { updateDeviceCameraInfoActionType } from '../redux/action-types'; -import { - type DeviceCameraInfo, - deviceCameraInfoPropType, -} from '../types/camera'; -import type { Orientations } from 'react-native-orientation-locker'; -import { - type InputState, - inputStatePropType, - InputStateContext, -} from '../input/input-state'; -import type { ViewStyle } from '../types/styles'; -import type { NativeMethods } from '../types/react-native'; -import type { AppNavigationProp } from '../navigation/app-navigator.react'; -import type { NavigationRoute } from '../navigation/route-names'; import type { Dispatch } from 'lib/types/redux-types'; - +import PropTypes from 'prop-types'; import * as React from 'react'; import { View, Text, StyleSheet, TouchableOpacity, Platform, Image, Animated, Easing, } from 'react-native'; -import PropTypes from 'prop-types'; -import Reanimated, { - Easing as ReanimatedEasing, -} from 'react-native-reanimated'; import { RNCamera } from 'react-native-camera'; +import filesystem from 'react-native-fs'; import { PinchGestureHandler, TapGestureHandler, State as GestureState, } from 'react-native-gesture-handler'; -import Icon from 'react-native-vector-icons/Ionicons'; import Orientation from 'react-native-orientation-locker'; -import invariant from 'invariant'; -import filesystem from 'react-native-fs'; +import type { Orientations } from 'react-native-orientation-locker'; +import Reanimated, { + Easing as ReanimatedEasing, +} from 'react-native-reanimated'; +import Icon from 'react-native-vector-icons/Ionicons'; import { useDispatch } from 'react-redux'; -import { pathFromURI, filenameFromPathOrURI } from 'lib/media/file-utils'; - -import ConnectedStatusBar from '../connected-status-bar.react'; -import { clamp, gestureJustEnded } from '../utils/animation-utils'; import ContentLoading from '../components/content-loading.react'; -import { colors } from '../themes/colors'; -import SendMediaButton from './send-media-button.react'; +import ConnectedStatusBar from '../connected-status-bar.react'; +import { + type InputState, + inputStatePropType, + InputStateContext, +} from '../input/input-state'; +import type { AppNavigationProp } from '../navigation/app-navigator.react'; import { OverlayContext, type OverlayContextType, overlayContextPropType, } from '../navigation/overlay-context'; +import type { NavigationRoute } from '../navigation/route-names'; +import { updateDeviceCameraInfoActionType } from '../redux/action-types'; +import { + type DimensionsInfo, + dimensionsInfoPropType, +} from '../redux/dimensions-updater.react'; import { useSelector } from '../redux/redux-utils'; +import { colors } from '../themes/colors'; +import { + type DeviceCameraInfo, + deviceCameraInfoPropType, +} from '../types/camera'; +import type { NativeMethods } from '../types/react-native'; +import type { ViewStyle } from '../types/styles'; +import { clamp, gestureJustEnded } from '../utils/animation-utils'; + +import SendMediaButton from './send-media-button.react'; /* eslint-disable import/no-named-as-default-member */ const { Value, Clock, event, Extrapolate, block, set, call, cond, not, and, or, eq, greaterThan, lessThan, add, sub, multiply, divide, abs, interpolate, startClock, stopClock, clockRunning, timing, spring, SpringUtils, } = Reanimated; /* eslint-enable import/no-named-as-default-member */ const maxZoom = 16; const zoomUpdateFactor = (() => { if (Platform.OS === 'ios') { return 0.002; } if (Platform.OS === 'android' && Platform.Version > 26) { return 0.005; } if (Platform.OS === 'android' && Platform.Version > 23) { return 0.01; } return 0.03; })(); const stagingModeAnimationConfig = { duration: 150, easing: ReanimatedEasing.inOut(ReanimatedEasing.ease), }; const sendButtonAnimationConfig = { duration: 150, easing: Easing.inOut(Easing.ease), useNativeDriver: true, }; const indicatorSpringConfig = { ...SpringUtils.makeDefaultConfig(), damping: 0, mass: 0.6, toValue: 1, }; const indicatorTimingConfig = { duration: 500, easing: ReanimatedEasing.out(ReanimatedEasing.ease), toValue: 0, }; function runIndicatorAnimation( // Inputs springClock: Clock, delayClock: Clock, timingClock: Clock, animationRunning: Value, // Outputs scale: Value, opacity: Value, ): Value { const delayStart = new Value(0); const springScale = new Value(0.75); const delayScale = new Value(0); const timingScale = new Value(0.75); const animatedScale = cond( clockRunning(springClock), springScale, cond(clockRunning(delayClock), delayScale, timingScale), ); const lastAnimatedScale = new Value(0.75); const numScaleLoops = new Value(0); const springState = { finished: new Value(1), velocity: new Value(0), time: new Value(0), position: springScale, }; const timingState = { finished: new Value(1), frameTime: new Value(0), time: new Value(0), position: timingScale, }; return block([ cond(not(animationRunning), [ set(springState.finished, 0), set(springState.velocity, 0), set(springState.time, 0), set(springScale, 0.75), set(lastAnimatedScale, 0.75), set(numScaleLoops, 0), set(opacity, 1), startClock(springClock), ]), [ cond( clockRunning(springClock), spring(springClock, springState, indicatorSpringConfig), ), timing(timingClock, timingState, indicatorTimingConfig), ], [ cond( and( greaterThan(animatedScale, 1.2), not(greaterThan(lastAnimatedScale, 1.2)), ), [ set(numScaleLoops, add(numScaleLoops, 1)), cond(greaterThan(numScaleLoops, 1), [ set(springState.finished, 1), stopClock(springClock), set(delayScale, springScale), set(delayStart, delayClock), startClock(delayClock), ]), ], ), set(lastAnimatedScale, animatedScale), ], cond( and( clockRunning(delayClock), greaterThan(delayClock, add(delayStart, 400)), ), [ stopClock(delayClock), set(timingState.finished, 0), set(timingState.frameTime, 0), set(timingState.time, 0), set(timingScale, delayScale), startClock(timingClock), ], ), cond( and(springState.finished, timingState.finished), stopClock(timingClock), ), set(scale, animatedScale), cond(clockRunning(timingClock), set(opacity, clamp(animatedScale, 0, 1))), ]); } export type CameraModalParams = {| presentedFrom: string, threadID: string, |}; type TouchableOpacityInstance = React.AbstractComponent< React.ElementConfig, NativeMethods, >; type BaseProps = {| +navigation: AppNavigationProp<'CameraModal'>, +route: NavigationRoute<'CameraModal'>, |}; type Props = {| ...BaseProps, // Redux state +dimensions: DimensionsInfo, +deviceCameraInfo: DeviceCameraInfo, +deviceOrientation: Orientations, +foreground: boolean, // Redux dispatch functions +dispatch: Dispatch, // withInputState +inputState: ?InputState, // withOverlayContext +overlayContext: ?OverlayContextType, |}; type State = {| +zoom: number, +useFrontCamera: boolean, +hasCamerasOnBothSides: boolean, +flashMode: number, +autoFocusPointOfInterest: ?{| x: number, y: number, autoExposure?: boolean, |}, +stagingMode: boolean, +pendingPhotoCapture: ?PhotoCapture, |}; class CameraModal extends React.PureComponent { static propTypes = { navigation: PropTypes.shape({ goBackOnce: PropTypes.func.isRequired, }).isRequired, route: PropTypes.shape({ params: PropTypes.shape({ threadID: PropTypes.string.isRequired, }).isRequired, }).isRequired, dimensions: dimensionsInfoPropType.isRequired, deviceCameraInfo: deviceCameraInfoPropType.isRequired, deviceOrientation: PropTypes.string.isRequired, foreground: PropTypes.bool.isRequired, dispatch: PropTypes.func.isRequired, inputState: inputStatePropType, overlayContext: overlayContextPropType, }; camera: ?RNCamera; pinchEvent; pinchHandler = React.createRef(); tapEvent; tapHandler = React.createRef(); animationCode: Value; closeButton: ?React.ElementRef; closeButtonX = new Value(-1); closeButtonY = new Value(-1); closeButtonWidth = new Value(0); closeButtonHeight = new Value(0); photoButton: ?React.ElementRef; photoButtonX = new Value(-1); photoButtonY = new Value(-1); photoButtonWidth = new Value(0); photoButtonHeight = new Value(0); switchCameraButton: ?React.ElementRef; switchCameraButtonX = new Value(-1); switchCameraButtonY = new Value(-1); switchCameraButtonWidth = new Value(0); switchCameraButtonHeight = new Value(0); flashButton: ?React.ElementRef; flashButtonX = new Value(-1); flashButtonY = new Value(-1); flashButtonWidth = new Value(0); flashButtonHeight = new Value(0); focusIndicatorX = new Value(-1); focusIndicatorY = new Value(-1); focusIndicatorScale = new Value(0); focusIndicatorOpacity = new Value(0); cancelIndicatorAnimation = new Value(0); cameraIDsFetched = false; stagingModeProgress = new Value(0); sendButtonProgress = new Animated.Value(0); sendButtonStyle: ViewStyle; overlayStyle: ViewStyle; constructor(props: Props) { super(props); this.state = { zoom: 0, useFrontCamera: props.deviceCameraInfo.defaultUseFrontCamera, hasCamerasOnBothSides: props.deviceCameraInfo.hasCamerasOnBothSides, flashMode: RNCamera.Constants.FlashMode.off, autoFocusPointOfInterest: undefined, stagingMode: false, pendingPhotoCapture: undefined, }; const sendButtonScale = this.sendButtonProgress.interpolate({ inputRange: [0, 1], outputRange: ([1.1, 1]: number[]), // Flow... }); this.sendButtonStyle = { opacity: this.sendButtonProgress, transform: [{ scale: sendButtonScale }], }; const overlayOpacity = interpolate(this.stagingModeProgress, { inputRange: [0, 0.01, 1], outputRange: [0, 0.5, 0], extrapolate: Extrapolate.CLAMP, }); this.overlayStyle = { ...styles.overlay, opacity: overlayOpacity, }; const pinchState = new Value(-1); const pinchScale = new Value(1); this.pinchEvent = event([ { nativeEvent: { state: pinchState, scale: pinchScale, }, }, ]); const tapState = new Value(-1); const tapX = new Value(0); const tapY = new Value(0); this.tapEvent = event([ { nativeEvent: { state: tapState, x: tapX, y: tapY, }, }, ]); this.animationCode = block([ this.zoomAnimationCode(pinchState, pinchScale), this.focusAnimationCode(tapState, tapX, tapY), ]); } zoomAnimationCode(pinchState: Value, pinchScale: Value): Value { const pinchJustEnded = gestureJustEnded(pinchState); const zoomBase = new Value(1); const zoomReported = new Value(1); const currentZoom = interpolate(multiply(zoomBase, pinchScale), { inputRange: [1, 8], outputRange: [1, 8], extrapolate: Extrapolate.CLAMP, }); const cameraZoomFactor = interpolate(zoomReported, { inputRange: [1, 8], outputRange: [0, 1], extrapolate: Extrapolate.CLAMP, }); const resolvedZoom = cond( eq(pinchState, GestureState.ACTIVE), currentZoom, zoomBase, ); return [ cond(pinchJustEnded, set(zoomBase, currentZoom)), cond( or( pinchJustEnded, greaterThan( abs(sub(divide(resolvedZoom, zoomReported), 1)), zoomUpdateFactor, ), ), [ set(zoomReported, resolvedZoom), call([cameraZoomFactor], this.updateZoom), ], ), ]; } focusAnimationCode(tapState: Value, tapX: Value, tapY: Value): Value { const lastTapX = new Value(0); const lastTapY = new Value(0); const fingerJustReleased = and( gestureJustEnded(tapState), this.outsideButtons(lastTapX, lastTapY), ); const indicatorSpringClock = new Clock(); const indicatorDelayClock = new Clock(); const indicatorTimingClock = new Clock(); const indicatorAnimationRunning = or( clockRunning(indicatorSpringClock), clockRunning(indicatorDelayClock), clockRunning(indicatorTimingClock), ); return [ cond(fingerJustReleased, [ call([tapX, tapY], this.focusOnPoint), set(this.focusIndicatorX, tapX), set(this.focusIndicatorY, tapY), stopClock(indicatorSpringClock), stopClock(indicatorDelayClock), stopClock(indicatorTimingClock), ]), cond(this.cancelIndicatorAnimation, [ set(this.cancelIndicatorAnimation, 0), stopClock(indicatorSpringClock), stopClock(indicatorDelayClock), stopClock(indicatorTimingClock), set(this.focusIndicatorOpacity, 0), ]), cond( or(fingerJustReleased, indicatorAnimationRunning), runIndicatorAnimation( indicatorSpringClock, indicatorDelayClock, indicatorTimingClock, indicatorAnimationRunning, this.focusIndicatorScale, this.focusIndicatorOpacity, ), ), set(lastTapX, tapX), set(lastTapY, tapY), ]; } outsideButtons(x: Value, y: Value) { const { closeButtonX, closeButtonY, closeButtonWidth, closeButtonHeight, photoButtonX, photoButtonY, photoButtonWidth, photoButtonHeight, switchCameraButtonX, switchCameraButtonY, switchCameraButtonWidth, switchCameraButtonHeight, flashButtonX, flashButtonY, flashButtonWidth, flashButtonHeight, } = this; return and( or( lessThan(x, closeButtonX), greaterThan(x, add(closeButtonX, closeButtonWidth)), lessThan(y, closeButtonY), greaterThan(y, add(closeButtonY, closeButtonHeight)), ), or( lessThan(x, photoButtonX), greaterThan(x, add(photoButtonX, photoButtonWidth)), lessThan(y, photoButtonY), greaterThan(y, add(photoButtonY, photoButtonHeight)), ), or( lessThan(x, switchCameraButtonX), greaterThan(x, add(switchCameraButtonX, switchCameraButtonWidth)), lessThan(y, switchCameraButtonY), greaterThan(y, add(switchCameraButtonY, switchCameraButtonHeight)), ), or( lessThan(x, flashButtonX), greaterThan(x, add(flashButtonX, flashButtonWidth)), lessThan(y, flashButtonY), greaterThan(y, add(flashButtonY, flashButtonHeight)), ), ); } static isActive(props) { const { overlayContext } = props; invariant(overlayContext, 'CameraModal should have OverlayContext'); return !overlayContext.isDismissing; } componentDidMount() { if (CameraModal.isActive(this.props)) { Orientation.unlockAllOrientations(); } } componentWillUnmount() { if (CameraModal.isActive(this.props)) { Orientation.lockToPortrait(); } } componentDidUpdate(prevProps: Props, prevState: State) { const isActive = CameraModal.isActive(this.props); const wasActive = CameraModal.isActive(prevProps); if (isActive && !wasActive) { Orientation.unlockAllOrientations(); } else if (!isActive && wasActive) { Orientation.lockToPortrait(); } if (!this.state.hasCamerasOnBothSides && prevState.hasCamerasOnBothSides) { this.switchCameraButtonX.setValue(-1); this.switchCameraButtonY.setValue(-1); this.switchCameraButtonWidth.setValue(0); this.switchCameraButtonHeight.setValue(0); } if (this.props.deviceOrientation !== prevProps.deviceOrientation) { this.setState({ autoFocusPointOfInterest: null }); this.cancelIndicatorAnimation.setValue(1); } if (this.props.foreground && !prevProps.foreground && this.camera) { this.camera.refreshAuthorizationStatus(); } if (this.state.stagingMode && !prevState.stagingMode) { this.cancelIndicatorAnimation.setValue(1); this.focusIndicatorOpacity.setValue(0); timing(this.stagingModeProgress, { ...stagingModeAnimationConfig, toValue: 1, }).start(); } else if (!this.state.stagingMode && prevState.stagingMode) { this.stagingModeProgress.setValue(0); } if (this.state.pendingPhotoCapture && !prevState.pendingPhotoCapture) { Animated.timing(this.sendButtonProgress, { ...sendButtonAnimationConfig, toValue: 1, }).start(); } else if ( !this.state.pendingPhotoCapture && prevState.pendingPhotoCapture ) { CameraModal.cleanUpPendingPhotoCapture(prevState.pendingPhotoCapture); this.sendButtonProgress.setValue(0); } } static async cleanUpPendingPhotoCapture(pendingPhotoCapture: PhotoCapture) { const path = pathFromURI(pendingPhotoCapture.uri); if (!path) { return; } try { await filesystem.unlink(path); } catch (e) {} } get containerStyle() { const { overlayContext } = this.props; invariant(overlayContext, 'CameraModal should have OverlayContext'); return { ...styles.container, opacity: overlayContext.position, }; } get focusIndicatorStyle() { return { ...styles.focusIndicator, opacity: this.focusIndicatorOpacity, transform: [ { translateX: this.focusIndicatorX }, { translateY: this.focusIndicatorY }, { scale: this.focusIndicatorScale }, ], }; } renderCamera = ({ camera, status }) => { if (camera && camera._cameraHandle) { this.fetchCameraIDs(camera); } if (this.state.stagingMode) { return this.renderStagingView(); } const topButtonStyle = { top: Math.max(this.props.dimensions.topInset, 6), }; return ( <> {this.renderCameraContent(status)} × ); }; renderStagingView() { let image = null; const { pendingPhotoCapture } = this.state; if (pendingPhotoCapture) { const imageSource = { uri: pendingPhotoCapture.uri }; image = ; } else { image = ; } const topButtonStyle = { top: Math.max(this.props.dimensions.topInset - 3, 3), }; const sendButtonContainerStyle = { bottom: this.props.dimensions.bottomInset + 22, }; return ( <> {image} ); } renderCameraContent(status) { if (status === 'PENDING_AUTHORIZATION') { return ; } else if (status === 'NOT_AUTHORIZED') { return ( {"don't have permission :("} ); } let switchCameraButton = null; if (this.state.hasCamerasOnBothSides) { switchCameraButton = ( ); } let flashIcon; if (this.state.flashMode === RNCamera.Constants.FlashMode.on) { flashIcon = ; } else if (this.state.flashMode === RNCamera.Constants.FlashMode.off) { flashIcon = ; } else { flashIcon = ( <> A ); } const topButtonStyle = { top: Math.max(this.props.dimensions.topInset - 3, 3), }; const bottomButtonsContainerStyle = { bottom: this.props.dimensions.bottomInset + 20, }; return ( {flashIcon} {switchCameraButton} ); } render() { const statusBar = CameraModal.isActive(this.props) ? (